From 23cdc54fd4b14b3903f09e32feffabfb8828db8f Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 30 May 2026 14:39:49 +0500 Subject: [PATCH 01/77] Add design spec for IMPROVEMENT-033: Equipment Set Rewards for Seasons Co-Authored-By: Claude Sonnet 4.6 --- ...033-equipment-set-season-rewards-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-improvement-033-equipment-set-season-rewards-design.md diff --git a/docs/superpowers/specs/2026-05-30-improvement-033-equipment-set-season-rewards-design.md b/docs/superpowers/specs/2026-05-30-improvement-033-equipment-set-season-rewards-design.md new file mode 100644 index 00000000..54672c6b --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-improvement-033-equipment-set-season-rewards-design.md @@ -0,0 +1,173 @@ +# IMPROVEMENT-033: Equipment Set Rewards for Seasons — Design + +**Date:** 2026-05-30 +**Branch:** p36.5 +**Status:** Approved + +--- + +## Overview + +At every reward grant point in the Seasons system — tier rewards, objective completion rewards, and leaderboard rewards — add support for specifying an **equipment set** as the reward instead of a fixed item package. When a reward of this type is granted, the player receives one randomly selected item from the named equipment set, delivered through the existing redeemable items pipeline. + +--- + +## Key Decision: Bypass `packageitems` + +The `packageitems` table is a server-side template only — the client never sees it. The client interacts exclusively with `accountredeemableitems (id, accountid, definition, quantity)`, which already holds resolved definitions. The random set resolution therefore happens at delivery time on the server, writing the chosen definition directly into `accountredeemableitems`. No client protocol changes are required. + +Rather than extending `packageitems` with a nullable `equipment_set_id`, the set ID is stored **directly on the season entity tables** (tiers, objectives, leaderboard rewards). This keeps `packageitems` dedicated to fixed item lists and avoids a nullable `definition` column there. + +--- + +## Section 1: Data Layer + +### Schema changes + +```sql +-- Add equipment_set_id to each season reward table +ALTER TABLE season_tiers ADD equipment_set_id INT NULL REFERENCES equipment_sets(set_id); +ALTER TABLE season_objectives ADD equipment_set_id INT NULL REFERENCES equipment_sets(set_id); +ALTER TABLE season_leaderboard_rewards ADD equipment_set_id INT NULL REFERENCES equipment_sets(set_id); + +-- Make package_id nullable where it was NOT NULL (tiers and leaderboard rewards) +-- (season_objectives.package_id is already nullable) +ALTER TABLE season_tiers ALTER COLUMN package_id INT NULL; +ALTER TABLE season_leaderboard_rewards ALTER COLUMN package_id INT NULL; +``` + +### Invariant + +Per row, exactly one of `package_id` / `equipment_set_id` is non-null. Enforced at the application layer: +- Admin Tool: validates before queuing a save; warns visually if neither field is set. +- Server: logs a warning and skips delivery if both are null at grant time. + +### Unchanged tables + +- `packageitems` — no changes. +- `accountredeemableitems` — no changes; client protocol unaffected. + +--- + +## Section 2: Server Runtime + +### Model changes (`SeasonModels.cs`) + +| Model | Change | +|---|---| +| `SeasonTier` | `PackageId`: `int` → `int?`; add `int? EquipmentSetId` | +| `SeasonLeaderboardReward` | `PackageId`: `int` → `int?`; add `int? EquipmentSetId` | +| `SeasonObjective` | add `int? EquipmentSetId` (`PackageId` already `int?`) | + +### Repository changes (`SeasonRepository.cs`) + +- **`GetTiers`, `GetObjectives`, `GetLeaderboardRewards`** — extend SELECT and mapping to read `equipment_set_id`. +- **`GetSetMemberDefinitions(int setId)`** — new method; queries `SELECT definition FROM equipment_set_members WHERE set_id = @setId`; returns `List`. +- **`InsertRedeemableItem(int accountId, int definition)`** — new helper; inserts one row into `accountredeemableitems` with `quantity = 1`. Set rewards always grant exactly one item. +- **`CloneSeasonForNextIteration`** — update the three `INSERT … SELECT` clone queries to include `equipment_set_id` in both column list and SELECT. + +### Delivery changes (`SeasonService.cs`) + +`DeliverTierReward`, `DeliverObjectivePackage`, and `DeliverLeaderboardReward` each apply the same branching pattern: + +``` +if equipmentSetId has value: + definitions = repository.GetSetMemberDefinitions(setId) + if definitions is empty: + log warning; return // no crash, no silent data corruption + pick definitions[new Random().Next(definitions.Count)] + repository.InsertRedeemableItem(accountId, pickedDefinition) +else if packageId has value: + existing GetPackageItems → InsertRedeemableItems path (unchanged) +else: + log warning; return +``` + +Random selection is uniform, drawn from `new Random()` at grant time. Determinism across grant events is not required. + +No zone update loop involvement. No blocking paths added. No hot-path impact. + +--- + +## Section 3: Admin Tool + +### Row model changes + +`SeasonTierRow`, `SeasonLeaderboardRewardRow`, `SeasonObjectiveRow` each receive: + +- `PackageId` becomes `int?` on the two that were non-nullable. +- `[ObservableProperty] int? _equipmentSetId` +- `[ObservableProperty] EquipmentSetRow? _selectedEquipmentSet` +- Partial callbacks that mutually clear the opposing field: + +```csharp +partial void OnSelectedPackageChanged(PackageRow? value) +{ + if (value != null) { PackageId = value.Id; EquipmentSetId = null; SelectedEquipmentSet = null; } +} + +partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) +{ + if (value != null) { EquipmentSetId = value.SetId; PackageId = null; SelectedPackage = null; } +} +``` + +### Repository changes (`AdminTool/Seasons/SeasonRepository.cs`) + +- `LoadTiersAsync`, `LoadObjectivesAsync`, `LoadLeaderboardRewardsAsync` — extend SELECT and mapping to read `equipment_set_id`. +- Add `LoadEquipmentSetsAsync()` — `SELECT set_id, name FROM equipment_sets ORDER BY name`; returns `List`. Reuses the same query shape already in `AdminTool/EquipmentSets/EquipmentSetRepository.cs`. + +### Changes (`SeasonChanges.cs`) + +All six build methods (insert/update for tier, objective, leaderboard reward) gain: + +```csharp +$"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)}" +``` + +in their SQL column lists. + +### ViewModel (`SeasonsViewModel.cs`) + +- Load the equipment sets list once when a season is selected; expose it as `IReadOnlyList` to the reward-row VMs. +- No new VM class required. + +### XAML + +On the tier editor, objective editor, and leaderboard reward editor panels: + +- Add a reward-type toggle (radio buttons or single `ComboBox`) with options **"Package"** and **"Equipment Set"**. +- Selecting "Package" shows the existing package dropdown; hides the equipment set dropdown. +- Selecting "Equipment Set" shows an equipment set dropdown; hides the package dropdown. +- If neither `PackageId` nor `EquipmentSetId` is set, show a red border or warning tooltip on the row. +- Non-blocking warning if the selected set has no members (server is the hard guard). + +--- + +## Error Handling + +| Scenario | Behaviour | +|---|---| +| Set has no members at grant time | Log warning; skip delivery; no exception | +| Both `package_id` and `equipment_set_id` null at grant time | Log warning; skip delivery; no exception | +| Set is deleted after being assigned to a reward row | Admin Tool warns on load if set ID no longer exists in the dropdown | + +--- + +## Regression Areas + +- Season tier delivery: `DeliverTierReward` branching must not change behaviour for rows with a valid `package_id`. +- `CloneSeasonForNextIteration`: must copy `equipment_set_id` for all three tables; missing it would silently drop set rewards on recurring season clones. +- `PackageId` nullability change on `SeasonTier` and `SeasonLeaderboardReward` — any call site that previously assumed non-null must be audited (delivery methods, Admin Tool SQL, clone query). + +--- + +## Manual Validation Steps + +1. Apply schema changes to the database. +2. Create a season tier with an equipment set reward (no package). Trigger the tier unlock for a test character. Verify one item from the set appears in redeemable items. +3. Create a daily objective with an equipment set reward. Complete it. Verify delivery. +4. Run season end with a leaderboard reward configured as a set. Verify winning characters receive a set item. +5. Verify existing package-based rewards on all three reward types still deliver correctly. +6. Clone a recurring season and confirm `equipment_set_id` is carried over to the new season rows. +7. Verify the Admin Tool reward-type toggle shows correctly for loaded rows, saves clean SQL, and warns when neither field is set. From afc6dd33970e8519e9a349fc44888ae4943cd558 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Sat, 30 May 2026 14:49:23 +0500 Subject: [PATCH 02/77] Add implementation plan for IMPROVEMENT-033: Equipment Set Season Rewards Co-Authored-By: Claude Sonnet 4.6 --- ...vement-033-equipment-set-season-rewards.md | 1332 +++++++++++++++++ 1 file changed, 1332 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-improvement-033-equipment-set-season-rewards.md diff --git a/docs/superpowers/plans/2026-05-30-improvement-033-equipment-set-season-rewards.md b/docs/superpowers/plans/2026-05-30-improvement-033-equipment-set-season-rewards.md new file mode 100644 index 00000000..4d987d35 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-improvement-033-equipment-set-season-rewards.md @@ -0,0 +1,1332 @@ +# IMPROVEMENT-033: Equipment Set Season Rewards — 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:** Allow season tier, objective, and leaderboard rewards to grant a random item from a named equipment set instead of (or instead of) a fixed item package. + +**Architecture:** `equipment_set_id INT NULL` is added directly to the three season reward tables; the existing `package_id` columns become nullable on tiers and leaderboard rewards. At delivery time, the server resolves the set to a random member definition and writes it to `accountredeemableitems` — the same pipeline the client already uses. No client protocol changes. The Admin Tool gains an Equipment Set ComboBox column alongside the existing Package column in each reward editor. + +**Tech Stack:** .NET 8 / C# 12, SQL Server, WPF + CommunityToolkit.Mvvm + +**Spec:** `docs/superpowers/specs/2026-05-30-improvement-033-equipment-set-season-rewards-design.md` + +--- + +## File Map + +**Modified — server:** +- `src/Perpetuum/Services/Seasons/SeasonModels.cs` — model property changes +- `src/Perpetuum/Services/Seasons/SeasonRepository.cs` — read path + two new methods + clone fix +- `src/Perpetuum/Services/Seasons/SeasonService.cs` — delivery branching + mail refactor + +**Modified — Admin Tool:** +- `src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs` +- `src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs` +- `src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs` +- `src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs` — six build methods +- `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` — three load methods + one new method +- `src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs` — EquipmentSets list, LoadAsync, new QueueSave command, AddTier/AddLeaderboardReward cleanup +- `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` — Equipment Set columns, Leaderboard Queue Save button + +--- + +## Task 1: Apply database schema changes + +**Files:** +- No code files — operator-run SQL DDL only + +These five statements must be applied to the live database **before** the updated server or Admin Tool is deployed. + +- [ ] **Step 1: Run the following SQL against the game database** + +```sql +-- Make package_id nullable on tables where it was NOT NULL +ALTER TABLE season_tiers ALTER COLUMN package_id INT NULL; +ALTER TABLE season_leaderboard_rewards ALTER COLUMN package_id INT NULL; + +-- Add equipment_set_id to all three reward tables +ALTER TABLE season_tiers ADD equipment_set_id INT NULL REFERENCES equipment_sets(set_id); +ALTER TABLE season_objectives ADD equipment_set_id INT NULL REFERENCES equipment_sets(set_id); +ALTER TABLE season_leaderboard_rewards ADD equipment_set_id INT NULL REFERENCES equipment_sets(set_id); +``` + +Expected: 5 rows affected, no errors. Existing rows retain their current `package_id` values; `equipment_set_id` defaults to NULL everywhere. + +- [ ] **Step 2: Verify** + +```sql +SELECT COLUMN_NAME, IS_NULLABLE, DATA_TYPE +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME IN ('season_tiers','season_objectives','season_leaderboard_rewards') + AND COLUMN_NAME IN ('package_id','equipment_set_id') +ORDER BY TABLE_NAME, COLUMN_NAME; +``` + +Expected: 6 rows — `equipment_set_id` is nullable on all three tables; `package_id` is nullable on `season_tiers` and `season_leaderboard_rewards`. + +- [ ] **Step 3: Commit note** + +No code to commit in this task. DDL is the deliverable. + +--- + +## Task 2: Update server models + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonModels.cs` + +- [ ] **Step 1: Apply changes to SeasonModels.cs** + +Replace the entire file content with: + +```csharp +namespace Perpetuum.Services.Seasons +{ + 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; } + public SeasonScoringMode ScoringMode { get; set; } + public int? DailyObjectivesPerDay { get; set; } + } + + public class SeasonActivityRate + { + public int Id { get; set; } + public int SeasonId { get; set; } + public SeasonActivityType ActivityType { get; set; } + public double PointsPerUnit { get; set; } + public int UnitScale { get; set; } + } + + public class SeasonObjective + { + public int Id { get; set; } + public int SeasonId { get; set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public SeasonActivityType ActivityType { get; set; } + public long TargetValue { get; set; } + public int BonusPoints { get; set; } + public int DisplayOrder { get; set; } + public bool IsDaily { get; set; } + public int? PackageId { get; set; } + public int? EquipmentSetId { get; set; } + public int? TargetDefinitionId { get; set; } + } + + public class SeasonTier + { + public int Id { get; set; } + public int SeasonId { get; set; } + public int TierNumber { get; set; } + public string TierName { get; set; } = ""; + public int PointsRequired { get; set; } + public int? PackageId { get; set; } + public int? EquipmentSetId { get; set; } + } + + public class SeasonLeaderboardReward + { + public int Id { get; set; } + public int SeasonId { get; set; } + public int RankMin { get; set; } + public int RankMax { get; set; } + public int? PackageId { get; set; } + public int? EquipmentSetId { get; set; } + } + + public class SeasonCharacterPoints + { + public int CharacterId { get; set; } + public int SeasonId { get; set; } + public double TotalPoints { get; set; } + public bool IntroMailSent { get; set; } + public bool LeaderboardRewardDelivered { get; set; } + } + + public class SeasonPackageItem + { + public int Definition { get; set; } + public int Quantity { get; set; } + } +} +``` + +- [ ] **Step 2: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonModels.cs +git commit -m "feat(seasons): add EquipmentSetId to reward models; PackageId nullable on tiers and leaderboard" +``` + +--- + +## Task 3: Update server repository — read path and clone + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonRepository.cs` + +Four methods change: `GetObjectives`, `GetTiers`, `GetLeaderboardRewards`, and `CloneSeasonForNextIteration`. + +- [ ] **Step 1: Update GetObjectives** + +Find the `GetObjectives` method. Replace its SQL and mapping: + +```csharp +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, equipment_set_id " + + "FROM season_objectives WHERE season_id = @seasonId ORDER BY display_order") + .SetParameter("@seasonId", seasonId) + .Execute() + .Select(r => new SeasonObjective + { + Id = r.GetValue("id"), + SeasonId = r.GetValue("season_id"), + Name = r.GetValue("name"), + Description = r.GetValue("description"), + ActivityType = (SeasonActivityType)r.GetValue("activity_type"), + TargetValue = r.GetValue("target_value"), + BonusPoints = r.GetValue("bonus_points"), + DisplayOrder = r.GetValue("display_order"), + IsDaily = r.GetValue("is_daily"), + PackageId = r.GetValue("package_id"), + TargetDefinitionId = r.GetValue("target_definition_id"), + EquipmentSetId = r.GetValue("equipment_set_id"), + }) + .ToList(); +} +``` + +- [ ] **Step 2: Update GetTiers** + +Replace the `GetTiers` method: + +```csharp +public List GetTiers(int seasonId) +{ + return Db.Query("SELECT id, season_id, tier_number, tier_name, points_required, " + + "package_id, equipment_set_id " + + "FROM season_tiers WHERE season_id = @seasonId ORDER BY tier_number") + .SetParameter("@seasonId", seasonId) + .Execute() + .Select(r => new SeasonTier + { + Id = r.GetValue("id"), + SeasonId = r.GetValue("season_id"), + TierNumber = r.GetValue("tier_number"), + TierName = r.GetValue("tier_name"), + PointsRequired = r.GetValue("points_required"), + PackageId = r.GetValue("package_id"), + EquipmentSetId = r.GetValue("equipment_set_id"), + }) + .ToList(); +} +``` + +- [ ] **Step 3: Update GetLeaderboardRewards** + +Replace the `GetLeaderboardRewards` method: + +```csharp +public List GetLeaderboardRewards(int seasonId) +{ + return Db.Query("SELECT id, season_id, rank_min, rank_max, package_id, equipment_set_id " + + "FROM season_leaderboard_rewards WHERE season_id = @seasonId") + .SetParameter("@seasonId", seasonId) + .Execute() + .Select(r => new SeasonLeaderboardReward + { + Id = r.GetValue("id"), + SeasonId = r.GetValue("season_id"), + RankMin = r.GetValue("rank_min"), + RankMax = r.GetValue("rank_max"), + PackageId = r.GetValue("package_id"), + EquipmentSetId = r.GetValue("equipment_set_id"), + }) + .ToList(); +} +``` + +- [ ] **Step 4: Update CloneSeasonForNextIteration — three INSERT…SELECT queries** + +In `CloneSeasonForNextIteration`, find the three `Db.Query(...)` calls that clone objectives, tiers, and leaderboard rewards. Replace each with the version below. + +Objectives clone — add `equipment_set_id` to column list and SELECT: + +```csharp +Db.Query( + "INSERT INTO season_objectives " + + "(season_id, name, description, activity_type, target_value, " + + "bonus_points, display_order, is_daily, package_id, equipment_set_id) " + + "SELECT @newId, name, description, activity_type, target_value, " + + "bonus_points, display_order, is_daily, package_id, equipment_set_id " + + "FROM season_objectives WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); +``` + +Tiers clone: + +```csharp +Db.Query( + "INSERT INTO season_tiers " + + "(season_id, tier_number, tier_name, points_required, package_id, equipment_set_id) " + + "SELECT @newId, tier_number, tier_name, points_required, package_id, equipment_set_id " + + "FROM season_tiers WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); +``` + +Leaderboard rewards clone: + +```csharp +Db.Query( + "INSERT INTO season_leaderboard_rewards " + + "(season_id, rank_min, rank_max, package_id, equipment_set_id) " + + "SELECT @newId, rank_min, rank_max, package_id, equipment_set_id " + + "FROM season_leaderboard_rewards WHERE season_id = @prevId") + .SetParameter("@newId", newId) + .SetParameter("@prevId", previous.Id) + .ExecuteNonQuery(); +``` + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonRepository.cs +git commit -m "feat(seasons): read equipment_set_id in GetTiers/GetObjectives/GetLeaderboardRewards; clone fix" +``` + +--- + +## Task 4: Add new server repository methods + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonRepository.cs` + +Add two new methods to `SeasonRepository`. A good location is immediately after `GetPackageItems`. + +- [ ] **Step 1: Add GetSetMemberDefinitions** + +```csharp +public List GetSetMemberDefinitions(int setId) +{ + return Db.Query("SELECT definition FROM equipment_set_members WHERE set_id = @setId") + .SetParameter("@setId", setId) + .Execute() + .Select(r => r.GetValue("definition")) + .ToList(); +} +``` + +- [ ] **Step 2: Add InsertRedeemableItem** + +Place this immediately after `InsertRedeemableItems`: + +```csharp +public void InsertRedeemableItem(int accountId, int definition) +{ + Db.Query("INSERT INTO accountredeemableitems (accountid, definition, quantity) " + + "VALUES (@accountId, @definition, 1)") + .SetParameter("@accountId", accountId) + .SetParameter("@definition", definition) + .ExecuteNonQuery().ThrowIfEqual(0, ErrorCodes.SQLInsertError); +} +``` + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonRepository.cs +git commit -m "feat(seasons): add GetSetMemberDefinitions and InsertRedeemableItem to SeasonRepository" +``` + +--- + +## Task 5: Update SeasonService delivery logic + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonService.cs` + +Four changes: refactor `SendTierUnlockMail`, update `DeliverTierReward`, update `DeliverObjectivePackage` (renamed), update `DeliverLeaderboardReward`, fix `RecordActivity` call site. + +- [ ] **Step 1: Refactor SendTierUnlockMail to accept items** + +The current `SendTierUnlockMail` re-fetches items internally from `tier.PackageId`. Change the signature to accept a pre-resolved list. Find the method and replace it: + +```csharp +private void SendTierUnlockMail(int characterId, SeasonTier tier, double total, + List items) +{ + var character = Character.Get(characterId); + var dict = _customDictionary.GetDictionary(0); + string subject = $"Tier Unlocked: {tier.TierName}"; + string body = $"You reached {tier.PointsRequired} season points and unlocked the {tier.TierName} tier reward!\n" + + $"Total points: {total}\n" + + $"Redeem your reward at any terminal via the Redeemable Items menu."; + MailHandler.SendMail(_announcer.Value, character, subject, body, + MailType.character, out _, out _); + + var chatMessage = new StringBuilder(); + chatMessage.AppendLine(); + chatMessage.AppendLine($"{character.Nick} just unlocked a new tier reward!"); + chatMessage.AppendLine(); + chatMessage.AppendLine($"Tier: {tier.TierName}"); + chatMessage.AppendLine($"Points: {total:N2}"); + chatMessage.AppendLine(); + chatMessage.AppendLine("Rewards:"); + + if (items.Count > 0) + { + foreach (var item in items) + { + var ed = EntityDefault.Reader.Get(item.Definition); + string name = (ed != null && ed != EntityDefault.None) + ? Translate(ed.Name, dict) + : item.Definition.ToString(); + chatMessage.AppendLine($"- {name} x{item.Quantity}"); + } + } + else + { + chatMessage.AppendLine("- Equipment set reward (check your Redeemable Items)"); + } + + chatMessage.AppendLine(); + chatMessage.AppendLine("Congratulations!"); + + _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); +} +``` + +- [ ] **Step 2: Replace DeliverTierReward** + +```csharp +private void DeliverTierReward(int characterId, int seasonId, SeasonTier tier, double currentPoints) +{ + var character = Character.Get(characterId); + + if (tier.EquipmentSetId.HasValue) + { + var definitions = _repository.GetSetMemberDefinitions(tier.EquipmentSetId.Value); + if (definitions.Count == 0) + { + System.Diagnostics.Trace.TraceWarning( + $"[SeasonService] Tier {tier.Id} equipment set {tier.EquipmentSetId} has no members; skipping reward."); + return; + } + var definition = definitions[new Random().Next(definitions.Count)]; + _repository.InsertRedeemableItem(character.AccountId, definition); + SendTierUnlockMail(characterId, tier, currentPoints, new List()); + } + else if (tier.PackageId.HasValue) + { + var items = _repository.GetPackageItems(tier.PackageId.Value); + if (items.Count == 0) + return; + _repository.InsertRedeemableItems(character.AccountId, tier.PackageId.Value, items); + SendTierUnlockMail(characterId, tier, currentPoints, items); + } + else + { + System.Diagnostics.Trace.TraceWarning( + $"[SeasonService] Tier {tier.Id} has neither package_id nor equipment_set_id; skipping reward."); + } +} +``` + +- [ ] **Step 3: Replace DeliverObjectivePackage** + +Rename the method to `DeliverObjectiveReward` and update its signature and body: + +```csharp +private void DeliverObjectiveReward(int characterId, int? packageId, int? equipmentSetId) +{ + var character = Character.Get(characterId); + + if (equipmentSetId.HasValue) + { + var definitions = _repository.GetSetMemberDefinitions(equipmentSetId.Value); + if (definitions.Count == 0) + { + System.Diagnostics.Trace.TraceWarning( + $"[SeasonService] Objective equipment set {equipmentSetId} has no members; skipping reward."); + return; + } + var definition = definitions[new Random().Next(definitions.Count)]; + _repository.InsertRedeemableItem(character.AccountId, definition); + } + else if (packageId.HasValue) + { + var items = _repository.GetPackageItems(packageId.Value); + if (items.Count == 0) + return; + _repository.InsertRedeemableItems(character.AccountId, packageId.Value, items); + } + else + { + System.Diagnostics.Trace.TraceWarning( + $"[SeasonService] Objective reward has neither package_id nor equipment_set_id; skipping."); + } +} +``` + +- [ ] **Step 4: Update the RecordActivity call site for objective rewards** + +In `RecordActivity`, find: + +```csharp +if (obj.IsDaily && obj.PackageId.HasValue) + DeliverObjectivePackage(characterId, obj.PackageId.Value); +``` + +Replace with: + +```csharp +if (obj.IsDaily && (obj.PackageId.HasValue || obj.EquipmentSetId.HasValue)) + DeliverObjectiveReward(characterId, obj.PackageId, obj.EquipmentSetId); +``` + +- [ ] **Step 5: Replace DeliverLeaderboardReward** + +```csharp +private void DeliverLeaderboardReward(int characterId, SeasonLeaderboardReward reward) +{ + var character = Character.Get(characterId); + + if (reward.EquipmentSetId.HasValue) + { + var definitions = _repository.GetSetMemberDefinitions(reward.EquipmentSetId.Value); + if (definitions.Count == 0) + { + System.Diagnostics.Trace.TraceWarning( + $"[SeasonService] Leaderboard reward {reward.Id} equipment set {reward.EquipmentSetId} has no members; skipping."); + return; + } + var definition = definitions[new Random().Next(definitions.Count)]; + _repository.InsertRedeemableItem(character.AccountId, definition); + } + else if (reward.PackageId.HasValue) + { + var items = _repository.GetPackageItems(reward.PackageId.Value); + if (items.Count == 0) + return; + _repository.InsertRedeemableItems(character.AccountId, reward.PackageId.Value, items); + } + else + { + System.Diagnostics.Trace.TraceWarning( + $"[SeasonService] Leaderboard reward {reward.Id} has neither package_id nor equipment_set_id; skipping."); + } +} +``` + +- [ ] **Step 6: Commit** + +``` +git add src/Perpetuum/Services/Seasons/SeasonService.cs +git commit -m "feat(seasons): delivery branching for equipment set rewards in tiers, objectives, leaderboard" +``` + +--- + +## Task 6: Build and verify server compiles + +**Files:** +- No changes — verification step only + +- [ ] **Step 1: Build the solution** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors. Common issues to check if errors appear: +- Any call site that used `tier.PackageId` as non-nullable `int` (now `int?`) — add `.Value` or `.HasValue` check +- Any call site that used `reward.PackageId` as non-nullable `int` — same +- The old `DeliverObjectivePackage(characterId, packageId)` call — should now be `DeliverObjectiveReward` +- `SendTierUnlockMail` call sites — must now pass a `List` as fourth argument + +- [ ] **Step 2: Commit if any fixes were needed** + +``` +git add -p +git commit -m "fix(seasons): compilation fixes after PackageId nullability change" +``` + +--- + +## Task 7: Update Admin Tool row models + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs` +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs` +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs` + +- [ ] **Step 1: Replace SeasonTierRow.cs** + +```csharp +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonTierRow : ObservableObject + { + public int Id { get; set; } + public int SeasonId { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private int _tierNumber; + [ObservableProperty] private string _tierName = ""; + [ObservableProperty] private int _pointsRequired; + [ObservableProperty] private int? _packageId; + [ObservableProperty] private PackageRow? _selectedPackage; + [ObservableProperty] private int? _equipmentSetId; + [ObservableProperty] private EquipmentSetRow? _selectedEquipmentSet; + + partial void OnSelectedPackageChanged(PackageRow? value) + { + if (value != null) + { + PackageId = value.Id; + EquipmentSetId = null; + _selectedEquipmentSet = null; + OnPropertyChanged(nameof(SelectedEquipmentSet)); + } + } + + partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) + { + if (value != null) + { + EquipmentSetId = value.SetId; + PackageId = null; + _selectedPackage = null; + OnPropertyChanged(nameof(SelectedPackage)); + } + } + } +} +``` + +- [ ] **Step 2: Replace SeasonLeaderboardRewardRow.cs** + +```csharp +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonLeaderboardRewardRow : ObservableObject + { + public int Id { get; set; } + public int SeasonId { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private int _rankMin = 1; + [ObservableProperty] private int _rankMax = 1; + [ObservableProperty] private int? _packageId; + [ObservableProperty] private PackageRow? _selectedPackage; + [ObservableProperty] private int? _equipmentSetId; + [ObservableProperty] private EquipmentSetRow? _selectedEquipmentSet; + + partial void OnSelectedPackageChanged(PackageRow? value) + { + if (value != null) + { + PackageId = value.Id; + EquipmentSetId = null; + _selectedEquipmentSet = null; + OnPropertyChanged(nameof(SelectedEquipmentSet)); + } + } + + partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) + { + if (value != null) + { + EquipmentSetId = value.SetId; + PackageId = null; + _selectedPackage = null; + OnPropertyChanged(nameof(SelectedPackage)); + } + } + } +} +``` + +- [ ] **Step 3: Update SeasonObjectiveRow.cs — add EquipmentSet fields** + +In `SeasonObjectiveRow.cs`, add the following two fields and their partial callbacks. Insert them after the `_selectedPackage` / `OnSelectedPackageChanged` block: + +```csharp +[ObservableProperty] private int? _equipmentSetId; +[ObservableProperty] private EquipmentSetRow? _selectedEquipmentSet; + +partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) +{ + if (value != null) + { + EquipmentSetId = value.SetId; + PackageId = null; + _selectedPackage = null; + OnPropertyChanged(nameof(SelectedPackage)); + } +} +``` + +Also update the existing `OnSelectedPackageChanged` partial to clear the set when a package is chosen: + +```csharp +partial void OnSelectedPackageChanged(PackageRow? value) +{ + if (value != null) + { + PackageId = value.Id; + EquipmentSetId = null; + _selectedEquipmentSet = null; + OnPropertyChanged(nameof(SelectedEquipmentSet)); + } + else + { + PackageId = value?.Id; + } +} +``` + +Add the using at the top: + +```csharp +using Perpetuum.AdminTool.EquipmentSets; +``` + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs +git add src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs +git add src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs +git commit -m "feat(admintool/seasons): add EquipmentSetId and SelectedEquipmentSet to reward row models" +``` + +--- + +## Task 8: Update SeasonChanges — all six build methods + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs` + +Replace the six affected methods. Leave `BuildInsert(SeasonRow)`, `BuildUpdate(SeasonRow)`, `BuildActivate`, `BuildDeactivate`, `BuildUpsertActivityRate`, `BuildDeleteObjective`, `BuildDeleteTier`, `BuildDeleteLeaderboardReward` unchanged. + +- [ ] **Step 1: Replace BuildInsertObjective** + +```csharp +public static IPendingChange BuildInsertObjective(SeasonObjectiveRow row) +{ + return new RawSqlChange( + $"season_objectives: insert '{row.Name}' in season {row.SeasonId}", + $"INSERT INTO season_objectives (season_id, name, description, activity_type, " + + $"target_value, bonus_points, display_order, is_daily, package_id, target_definition_id, equipment_set_id) VALUES (" + + $"{row.SeasonId}, {SqlLiteral.Of(row.Name)}, {SqlLiteral.Of(row.Description)}, " + + $"{(int)row.ActivityType}, {row.TargetValue}, {row.BonusPoints}, {row.DisplayOrder}, " + + $"{(row.IsDaily ? 1 : 0)}, {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"{SqlLiteral.OfNullableInt(row.TargetDefinitionId)}, {SqlLiteral.OfNullableInt(row.EquipmentSetId)})"); +} +``` + +- [ ] **Step 2: Replace BuildUpdateObjective** + +```csharp +public static IPendingChange BuildUpdateObjective(SeasonObjectiveRow row) +{ + return new RawSqlChange( + $"season_objectives: update id {row.Id}", + $"UPDATE season_objectives SET name = {SqlLiteral.Of(row.Name)}, " + + $"description = {SqlLiteral.Of(row.Description)}, " + + $"activity_type = {(int)row.ActivityType}, target_value = {row.TargetValue}, " + + $"bonus_points = {row.BonusPoints}, display_order = {row.DisplayOrder}, " + + $"is_daily = {(row.IsDaily ? 1 : 0)}, package_id = {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"target_definition_id = {SqlLiteral.OfNullableInt(row.TargetDefinitionId)}, " + + $"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)} " + + $"WHERE id = {row.Id}"); +} +``` + +- [ ] **Step 3: Replace BuildInsertTier** + +```csharp +public static IPendingChange BuildInsertTier(SeasonTierRow row) => + new RawSqlChange( + $"season_tiers: insert tier {row.TierNumber} ('{row.TierName}') in season {row.SeasonId}", + $"INSERT INTO season_tiers (season_id, tier_number, tier_name, points_required, package_id, equipment_set_id) VALUES (" + + $"{row.SeasonId}, {row.TierNumber}, {SqlLiteral.Of(row.TierName)}, {row.PointsRequired}, " + + $"{SqlLiteral.OfNullableInt(row.PackageId)}, {SqlLiteral.OfNullableInt(row.EquipmentSetId)})"); +``` + +- [ ] **Step 4: Replace BuildUpdateTier** + +```csharp +public static IPendingChange BuildUpdateTier(SeasonTierRow row) => + new RawSqlChange( + $"season_tiers: update id {row.Id}", + $"UPDATE season_tiers SET tier_number = {row.TierNumber}, tier_name = {SqlLiteral.Of(row.TierName)}, " + + $"points_required = {row.PointsRequired}, package_id = {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)} WHERE id = {row.Id}"); +``` + +- [ ] **Step 5: Replace BuildInsertLeaderboardReward** + +```csharp +public static IPendingChange BuildInsertLeaderboardReward(SeasonLeaderboardRewardRow row) => + new RawSqlChange( + $"season_leaderboard_rewards: insert ranks {row.RankMin}-{row.RankMax} in season {row.SeasonId}", + $"INSERT INTO season_leaderboard_rewards (season_id, rank_min, rank_max, package_id, equipment_set_id) VALUES (" + + $"{row.SeasonId}, {row.RankMin}, {row.RankMax}, " + + $"{SqlLiteral.OfNullableInt(row.PackageId)}, {SqlLiteral.OfNullableInt(row.EquipmentSetId)})"); +``` + +- [ ] **Step 6: Replace BuildUpdateLeaderboardReward** + +```csharp +public static IPendingChange BuildUpdateLeaderboardReward(SeasonLeaderboardRewardRow row) => + new RawSqlChange( + $"season_leaderboard_rewards: update id {row.Id}", + $"UPDATE season_leaderboard_rewards SET rank_min = {row.RankMin}, rank_max = {row.RankMax}, " + + $"package_id = {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)} WHERE id = {row.Id}"); +``` + +- [ ] **Step 7: Commit** + +``` +git add src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs +git commit -m "feat(admintool/seasons): add equipment_set_id to all six SeasonChanges build methods" +``` + +--- + +## Task 9: Update Admin Tool SeasonRepository + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs` + +- [ ] **Step 1: Update LoadObjectivesAsync — add equipment_set_id** + +In the SELECT command text, add `equipment_set_id` after `target_definition_id`. In the row construction, add: + +```csharp +EquipmentSetId = reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11), +``` + +The full updated SELECT: +```sql +SELECT id, season_id, name, description, activity_type, + target_value, bonus_points, display_order, is_daily, package_id, + target_definition_id, equipment_set_id +FROM season_objectives WHERE season_id = @seasonId ORDER BY display_order +``` + +And the updated object construction (indices 0–11): +```csharp +result.Add(new SeasonObjectiveRow +{ + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + Name = reader.IsDBNull(2) ? "" : reader.GetString(2), + Description = reader.IsDBNull(3) ? "" : reader.GetString(3), + ActivityType = (SeasonActivityType)reader.GetInt32(4), + TargetValue = reader.GetInt64(5), + BonusPoints = reader.GetInt32(6), + DisplayOrder = reader.GetInt32(7), + IsDaily = !reader.IsDBNull(8) && reader.GetBoolean(8), + PackageId = reader.IsDBNull(9) ? (int?)null : reader.GetInt32(9), + TargetDefinitionId = reader.IsDBNull(10) ? (int?)null : reader.GetInt32(10), + EquipmentSetId = reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11), +}); +``` + +- [ ] **Step 2: Update LoadTiersAsync — add equipment_set_id** + +Updated SELECT: +```sql +SELECT id, season_id, tier_number, tier_name, points_required, + package_id, equipment_set_id +FROM season_tiers WHERE season_id = @seasonId ORDER BY tier_number +``` + +Updated object construction: +```csharp +result.Add(new SeasonTierRow +{ + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + TierNumber = reader.GetInt32(2), + TierName = reader.IsDBNull(3) ? "" : reader.GetString(3), + PointsRequired = reader.GetInt32(4), + PackageId = reader.IsDBNull(5) ? (int?)null : reader.GetInt32(5), + EquipmentSetId = reader.IsDBNull(6) ? (int?)null : reader.GetInt32(6), +}); +``` + +- [ ] **Step 3: Update LoadLeaderboardRewardsAsync — add equipment_set_id** + +Updated SELECT: +```sql +SELECT id, season_id, rank_min, rank_max, package_id, equipment_set_id +FROM season_leaderboard_rewards WHERE season_id = @seasonId ORDER BY rank_min +``` + +Updated object construction: +```csharp +result.Add(new SeasonLeaderboardRewardRow +{ + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + RankMin = reader.GetInt32(2), + RankMax = reader.GetInt32(3), + PackageId = reader.IsDBNull(4) ? (int?)null : reader.GetInt32(4), + EquipmentSetId = reader.IsDBNull(5) ? (int?)null : reader.GetInt32(5), +}); +``` + +- [ ] **Step 4: Add LoadEquipmentSetsAsync** + +Add this method to `SeasonRepository`. It queries the same table as `AdminTool/EquipmentSets/EquipmentSetRepository.LoadAllSetsAsync()`: + +```csharp +public async Task> LoadEquipmentSetsAsync() +{ + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT set_id, name FROM equipment_sets ORDER BY name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new EquipmentSetRow + { + SetId = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + }); + return result; +} +``` + +Add the using at the top of the file: + +```csharp +using Perpetuum.AdminTool.EquipmentSets; +``` + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs +git commit -m "feat(admintool/seasons): read equipment_set_id in load methods; add LoadEquipmentSetsAsync" +``` + +--- + +## Task 10: Update SeasonDetailViewModel + +**Files:** +- Modify: `src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs` + +Six changes: add `EquipmentSets` property, update `LoadAsync`, fix `AddTier`, add `QueueSaveLeaderboardRewardCommand`, fix `AddLeaderboardReward`. + +- [ ] **Step 1: Add EquipmentSets property** + +In the field declarations section (near `public ObservableCollection Packages { get; }`), add: + +```csharp +public IReadOnlyList EquipmentSets { get; private set; } = + Array.Empty(); +``` + +Add the using: + +```csharp +using Perpetuum.AdminTool.EquipmentSets; +``` + +- [ ] **Step 2: Update LoadAsync — load equipment sets and wire SelectedEquipmentSet on rows** + +At the start of the `try` block in `LoadAsync`, before the `BuildMaterialLists` call or immediately after, add: + +```csharp +EquipmentSets = await _repo.LoadEquipmentSetsAsync(); +OnPropertyChanged(nameof(EquipmentSets)); +``` + +In the `Objectives.Clear()` block, update the objective loading loop: + +```csharp +Objectives.Clear(); +if (Season.Id > 0) + foreach (var o in await _repo.LoadObjectivesAsync(Season.Id)) + { + if (o.PackageId.HasValue) + o.SelectedPackage = Packages.FirstOrDefault(p => p.Id == o.PackageId); + if (o.EquipmentSetId.HasValue) + o.SelectedEquipmentSet = EquipmentSets.FirstOrDefault(s => s.SetId == o.EquipmentSetId); + o.InitializeMaterialLists(_oreAndLiquidMaterials, _organicMaterials); + Objectives.Add(o); + } +``` + +In the `Tiers.Clear()` block, update the tier loading loop: + +```csharp +Tiers.Clear(); +if (Season.Id > 0) + foreach (var t in await _repo.LoadTiersAsync(Season.Id)) + { + if (t.PackageId.HasValue) + t.SelectedPackage = Packages.FirstOrDefault(p => p.Id == t.PackageId); + if (t.EquipmentSetId.HasValue) + t.SelectedEquipmentSet = EquipmentSets.FirstOrDefault(s => s.SetId == t.EquipmentSetId); + Tiers.Add(t); + } +``` + +In the `LeaderboardRewards.Clear()` block: + +```csharp +LeaderboardRewards.Clear(); +if (Season.Id > 0) + foreach (var l in await _repo.LoadLeaderboardRewardsAsync(Season.Id)) + { + if (l.PackageId.HasValue) + l.SelectedPackage = Packages.FirstOrDefault(p => p.Id == l.PackageId); + if (l.EquipmentSetId.HasValue) + l.SelectedEquipmentSet = EquipmentSets.FirstOrDefault(s => s.SetId == l.EquipmentSetId); + LeaderboardRewards.Add(l); + } +``` + +- [ ] **Step 3: Fix AddTier — remove Packages guard, clear default reward** + +Replace the `AddTier` command body with: + +```csharp +[RelayCommand] +private void AddTier() +{ + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var row = new SeasonTierRow + { + SeasonId = Season.Id, + TierNumber = Tiers.Count + 1, + TierName = $"Tier {Tiers.Count + 1}", + PointsRequired = (Tiers.Count + 1) * 1000, + IsNew = true + }; + Tiers.Add(row); + StatusIsError = false; + StatusMessage = "Added tier row. Set a Package or Equipment Set reward, then click 'Queue Save'."; +} +``` + +- [ ] **Step 4: Add QueueSaveLeaderboardRewardCommand** + +Add this command method to `SeasonDetailViewModel`: + +```csharp +[RelayCommand] +private void QueueSaveLeaderboardReward(SeasonLeaderboardRewardRow? row) +{ + if (row == null) return; + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + row.SeasonId = Season.Id; + if (row.Id == 0) + { + _queue.Add(SeasonChanges.BuildInsertLeaderboardReward(row)); + StatusMessage = $"Queued INSERT for leaderboard reward (ranks {row.RankMin}-{row.RankMax})."; + } + else + { + _queue.Add(SeasonChanges.BuildUpdateLeaderboardReward(row)); + StatusMessage = $"Queued UPDATE for leaderboard reward id {row.Id}."; + } + StatusIsError = false; +} +``` + +- [ ] **Step 5: Fix AddLeaderboardReward — remove Packages guard and auto-queue** + +Replace the `AddLeaderboardReward` command body with: + +```csharp +[RelayCommand] +private void AddLeaderboardReward() +{ + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var row = new SeasonLeaderboardRewardRow + { + SeasonId = Season.Id, + RankMin = 1, + RankMax = 1, + IsNew = true + }; + LeaderboardRewards.Add(row); + StatusIsError = false; + StatusMessage = "Added leaderboard reward row. Set ranks and a reward, then click 'Queue Save'."; +} +``` + +- [ ] **Step 6: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs +git commit -m "feat(admintool/seasons): EquipmentSets list, SelectedEquipmentSet wiring, QueueSaveLeaderboardReward command" +``` + +--- + +## Task 11: Update SeasonDetailView.xaml + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml` + +Three tabs need changes: Objectives (tab 2), Tiers (tab 3), Leaderboard (tab 4). + +- [ ] **Step 1: Objectives tab — update Reward Package column and add Equipment Set column** + +Find the `DataGridTemplateColumn` with `Header="Reward Package"` (around line 248). Replace it with two columns: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Tiers tab — update Reward Package column and add Equipment Set column** + +Find the `DataGridTemplateColumn` with `Header="Reward Package"` in the Tiers tab (around line 304). Replace it with two columns (same pattern as step 1, but `Width="*"` on the first): + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Leaderboard tab — update Reward Package column, add Equipment Set column, add Queue Save button** + +Find the `DataGridTemplateColumn` with `Header="Reward Package"` in the Leaderboard tab (around line 359). Replace it with two columns plus a Queue Save button column. The Remove button column stays at the end. + +Replace the existing single "Reward Package" column with: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Then add a Queue Save column before the existing Remove column: + +```xml + + + +