diff --git a/docs/Patches/p36.5/Features/EquipmentSetSeasonRewards/migration.sql b/docs/Patches/p36.5/Features/EquipmentSetSeasonRewards/migration.sql new file mode 100644 index 00000000..374f5714 --- /dev/null +++ b/docs/Patches/p36.5/Features/EquipmentSetSeasonRewards/migration.sql @@ -0,0 +1,11 @@ +-- Equipment Set Season Rewards Migration (IMPROVEMENT-033) +-- Run once against the game database before deploying the updated server binary. + +-- 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 season 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); diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index a88e4cf5..79d49376 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -1,6 +1,234 @@ # Last ID used -031 +039 + +## IMPROVEMENT-039 - Add economy health statistics beyond NIC flow reporting + +Status: DONE +Priority: HIGH +Area: Admin Tool / Economy + +### Description +The current economy report (IMPROVEMENT-034) tracks NIC flows (injections and sinks) but flow data alone is insufficient to evaluate true economy health. NIC flows show the rate of money creation and destruction — they do not show whether that money is circulating, concentrating, or causing real price inflation. Additional statistics are needed to give operators a complete diagnostic picture. + +### Impact +Without supplementary statistics, operators cannot distinguish between a healthy growing economy and a stagnating inflationary one with the same NIC flow numbers. These metrics close the gap between "what is happening to NIC" and "what is happening to the economy." + +### Proposed Statistics + +#### Money Supply +- **Total NIC in circulation** — sum of all player and corporation wallet balances. Without this, the net surplus figures have no denominator; a +7.5B monthly surplus means something very different on a 10B vs 1T money supply. +- **Money supply trend** — total NIC in circulation over time (daily/weekly snapshots), the clearest single indicator of inflation pressure. + +#### Wealth Distribution +- **Top 10 / top 1% wealth share** — what fraction of total NIC is held by the wealthiest players. High concentration means most players feel poor even if aggregate NIC is growing. +- **Median player wallet balance** — more representative than mean; large outliers skew mean heavily. +- **Idle NIC** — NIC held in wallets untouched for 30+ days. High idle NIC suggests players have nothing to spend it on. + +#### Market Health +- **Market price index** — average transaction price for a basket of common goods (raw materials, common robot parts, basic consumables) tracked over time. This is the direct inflation indicator — rising prices confirm what NIC flow data only implies. +- **Market velocity** — total NIC value of completed market transactions per day. Low velocity with high money supply = hoarding, not circulation. +- **Unsold listing age distribution** — how long goods sit on the market before selling or expiring. Aging listings indicate insufficient demand. +- **AutoMarket vs player market share** — what percentage of economic activity is AutoMarket-driven vs player-driven. High AutoMarket share on a low-pop server is expected; a declining player share over time signals disengagement. + +#### Sink Effectiveness +- **NIC sink breakdown per activity type** — how much each sink category contributes per active player, normalized by session count or login days. Reveals which sinks are load-bearing vs cosmetic. +- **Insurance coverage rate** — percentage of active robots that currently have insurance. Near-zero confirms the insurance system is effectively unused. + +### Notes +- Cross-reference IMPROVEMENT-034 (NIC flow report) — these statistics extend that panel, not replace it +- Total NIC in circulation is the highest-priority addition; without it, all flow data lacks context +- Market price index requires selecting a representative basket of goods — coordinate with game design intent +- Some of these (wealth distribution, idle NIC) may have privacy/fairness implications if exposed to players; restrict to admin view only + +### Implementation + +Implemented as four tabs in the Economy Admin Tool panel (branch p36.5, commits 930c727 → f9a5cc1): + +**Tab 1 — NIC Flow:** Existing panel extracted to `EconomyNicFlowViewModel` / `EconomyNicFlowView`. + +**Tab 2 — Money Supply & Wealth:** Total NIC in circulation (characters.credit + corporations.wallet), 90-day trend from `economy_daily_snapshot` (written daily by `EconomySnapshotService`), top-10 wealth leaderboard, median wallet, top-1% share, idle NIC (≥30 days inactive). + +**Tab 3 — Market Health:** Market velocity (daily NIC transacted from `marketaverageprices`), weighted price index for a configurable basket of items (`economy_price_index_basket`), live listing age distribution, AutoMarket vs player order mix. Basket items are editable via the global ChangeQueue. + +**Tab 4 — Sink Effectiveness:** NIC-out per category normalized by 30-day active player count, insurance coverage rate. + +**Server:** `EconomySnapshotService : IProcess` fires `usp_RecordEconomySnapshot` on startup and daily (idempotent MERGE on snapshot_date). + +**DB migration required:** `docs/db_structure/migrations/IMPROVEMENT-039-economy-health.sql` + +Design spec: `docs/superpowers/specs/2026-06-03-economy-health-stats-design.md` +Implementation plan: `docs/superpowers/plans/2026-06-03-economy-health-stats.md` + +--- + +## IMPROVEMENT-038 - Explore and expand AutoMarket Plasma rate tuning tools + +Status: TODO +Priority: HIGH +Area: AutoMarket / Economy / Admin Tool + +### Description +AutoMarket Plasma is the single largest NIC injection source (~3.99B NIC in the last 30 days, ~46% of all injections). Operators currently have no confirmed tooling to tune plasma buy rates. Existing tools must be audited and, if insufficient, new admin controls must be added to allow safe, incremental rate adjustment without code deployments. + +### Impact +Without operator control over plasma rates, the server has no practical lever to reduce the dominant inflation driver short of a code change and redeployment. Tunable rates would allow economy balancing to happen at runtime in response to observed NIC flow data. + +### Investigation Scope +1. Audit existing admin tools and configuration for any plasma rate controls (rate multipliers, price floors/ceilings, per-item overrides) +2. Check whether plasma rates are hardcoded, database-driven, or formula-based +3. Determine what inputs drive the current rate (supply/demand history, fixed table, dynamic calculation) +4. Assess whether existing controls are sufficient for meaningful economy tuning + +### Proposed additions (if controls are missing or insufficient) +- Admin Tool UI controls to adjust plasma rate multiplier or absolute price per commodity +- Per-item or per-category rate overrides stored in the database (not hardcoded) +- Rate change audit log so operators can track adjustments and correlate with economy report data +- Guardrails: min/max clamps to prevent accidental zero-rate or runaway injection + +### Notes +- Cross-reference IMPROVEMENT-034 (economy report) — plasma NIC flows are already visible there; rate controls would close the loop from observation to action +- Cross-reference IMPROVEMENT-035 (AutoMarket supply/demand) — any rate tuning should remain consistent with the existing ds_min/ds_max clamping architecture +- Changes to plasma rates have direct, immediate impact on the largest injection source — changes should be incremental and monitored + +--- + +## IMPROVEMENT-037 - Investigate System Credits & Refunds NIC injection source + +Status: TODO +Priority: HIGH +Area: Economy / NIC Flows + +### Description +The economy report shows System Credits & Refunds injected ~2.87B NIC in the last 30 days — roughly 33% of all server-side NIC injections. This is the second-largest injection source after AutoMarket Plasma, yet its origin and legitimacy are unclear. A full investigation is required. + +### Impact +At ~95M NIC/day, this source alone is a significant inflation driver. If it represents legitimate gameplay mechanics (NPC trade refunds, mission cancellations, system compensations) it should be documented and tuned. If it is a bug, misconfiguration, or exploitable pathway, it must be fixed immediately. + +### Investigation Scope +1. Identify all code paths that record a transaction under the "System Credits & Refunds" category +2. Determine whether each path is intentional design or a side-effect/bug +3. Check whether players can trigger refunds repeatedly or artificially (exploit vector) +4. Assess the expected volume — is 2.87B/month reasonable given current player activity, or anomalously high? +5. Cross-reference with player activity logs to see if a small number of accounts are responsible for a disproportionate share + +### Notes +- Cross-reference IMPROVEMENT-034 (economy report) — this source is already tracked there +- If the source is legitimate but oversized, consider capping or rate-limiting refund eligibility +- If exploit-driven, cross-reference ISSUE backlog for related economy abuse issues + +--- + +## IMPROVEMENT-036 - Investigate and improve the insurance system + +Status: DONE +Priority: HIGH +Area: Economy / Insurance + +### Description +The economy report shows Insurance Payouts = 0 for the last 30 days while Insurance Fees are near zero (70k/30d). The insurance system is either broken, unused, or being bypassed. This warrants a full investigation into how the system works, whether players can exploit it, and how it can be improved as a meaningful NIC sink. + +### Impact +Insurance was presumably designed as a significant NIC sink (loss recovery funded by premium fees). With it effectively dormant, the economy loses a major pressure valve, contributing to ~7.58B NIC/month surplus and long-term inflation. Restoring or redesigning it could meaningfully reduce inflation without punishing active gameplay. + +### Investigation Scope +1. Trace the full insurance lifecycle: premium charging, policy storage, payout triggering, NIC flow +2. Determine why payouts are zero — broken trigger, player avoidance, or design gap +3. Identify exploit vectors: avoiding premiums while still being eligible for payouts, double-claiming, gaming the payout calculation +4. Assess whether the current payout/fee ratio creates a net NIC sink or net NIC source +5. Propose rebalancing or redesign to make insurance a reliable and meaningful sink + +### Proposed Improvements (to evaluate) +- Ensure insurance fees are charged consistently on all eligible assets +- Ensure payout triggers fire correctly on robot destruction +- Cap payout-to-fee ratio to guarantee insurance is always net-negative for the economy +- Consider making insurance opt-out rather than opt-in to increase coverage and fee collection +- Add insurance NIC flows to the economy report for ongoing monitoring + +### Notes +- Cross-reference IMPROVEMENT-034 (economy report) — insurance flows are already surfaced there, confirming the zero-payout anomaly +- Insurance Fees (NIC Out) and Insurance Payouts (NIC In) must both be audited — a payout exceeding fees collected would make insurance a net injector, worsening inflation + +### Implementation + +Implemented on branch p36.5 (commits 36cf271 → e0e1dac): + +- `insurance_config` table: `fee_pct = 0.10`, `payout_pct = 0.08` (operator-tunable) +- `usp_RecalculateInsurancePrices`: MERGE from `v_all_production_costs` into `insuranceprices`; guards against `payout_pct >= fee_pct` +- `InsurancePriceRefreshService`: daily auto-refresh + startup run, flushes in-memory cache after each run +- `InsuraceFacility`: fee extension bonus (`ext_production_insurance_fee`) now applied at both purchase and quote +- Dead static multipliers (`InsuranceFeeMultiplier`, `InsurancePayOutMultiplier`) removed from `InsuranceHelper` +- Migration deletes stale `insurance` policies, then seeds correct prices; apply while server is OFFLINE +- Admin Tool: "Insurance" tab (5th in Economy panel) with config editor, price table, Reload and Recalculate Now buttons + +--- + +## IMPROVEMENT-035 - Factor player buy/sell orders into AutoMarket supply/demand rate calculation + +Status: DEFERRED +Priority: MEDIUM +Area: AutoMarket / Economy + +### Description +AutoMarket currently calculates supply and demand rates using only its own transaction history. Player-created buy and sell orders on the market represent real demand and supply signals that AutoMarket ignores. Including them in the rate calculation could produce more accurate pricing. + +### Analysis Outcome (2026-06-03) + +Full brainstorming and economic modelling completed. Decision: **defer**. + +**Benefit is small in practice.** On a low-population server, player raw material order volume is thin — the signal would be near-zero most of the time, producing behaviour identical to today. The improvement only matters at population peaks. + +**The existing system already captures most of the signal indirectly.** Product sell-through → `automarket_unsold_leftovers` → AutoMarket buys more raw materials next refresh. This indirect loop is slower but manipulation-proof. + +**Manipulation guard is structurally weak.** A 30-minute age filter stops rapid pump-cancel cycles but not fake 1-NIC buy orders left open for 24 hours, which cost nothing to place. Closing that hole properly requires either a price floor on counted orders (circular dependency on the price being computed) or per-character quantity caps — roughly doubling implementation complexity. + +**Manipulation ceiling:** ds_min/ds_max clamp [0.25, 4.0] and `daily_rawmat_budget_nic` bound the worst-case damage, but a coordinated attack on all raw materials simultaneously is a systemic risk. + +### Conditions to Revisit + +Reconsider only when: +1. IMPROVEMENT-034 (NIC flow statistics) is in place and provides operator visibility into raw material price trends. +2. That data shows a concrete, sustained divergence between AutoMarket raw material prices and player market prices that the existing indirect feedback loop is not correcting. +3. Population is high enough for player order volume to constitute a meaningful signal (not just noise). + +### Notes +- Cross-reference ISSUE-022 (order placement exploit) — same class of abuse applies here. +- Cross-reference IMPROVEMENT-034 — prerequisite for gathering the data needed to justify revisiting. + +--- + +## IMPROVEMENT-034 - Expand AutoMarket NIC flow statistics in Admin Tool + +Status: DONE +Priority: MEDIUM +Area: Admin Tool / Economy + +### Description +The AutoMarket tab in the Admin Tool currently shows limited statistics. It needs a full NIC flow breakdown — both income and outgoing — to give operators a complete picture of the server economy. This includes, but is not limited to: market taxes, transaction fees, mission rewards, crafting costs, repair fees, insurance payouts, and any other server-side NIC sources or sinks. + +### Impact +Without full NIC flow visibility, operators cannot diagnose inflation, NIC sinks underperforming, or unexpected injections. A comprehensive view enables data-driven economy tuning and early detection of exploits or misconfigurations. + +### Implementation + +Implemented as a new top-level **Economy** panel in the Admin Tool (separate from AutoMarket). Data sourced from existing `charactertransactions` and `corporationtransactions` tables (classified by `transactiontype` into named categories) plus `plasma_sold` and `rawmat_purchased` for AutoMarket flows. No schema changes or server-side code changes required. + +**NIC In categories:** Mission Rewards, Insurance Payouts, Intrusion Income, AutoMarket Plasma, System Credits & Refunds. + +**NIC Out categories:** Market Fees & Taxes, Production Costs, Repair Costs, Insurance Fees, Infrastructure Costs, Extension Learning, Spark Costs, Corporate & Alliance Fees, Other Fees, AutoMarket Raw Materials. + +Time periods: Today / Last 7 Days / Last 30 Days / All Time. Net balance shown with green/red coloring. + +Design spec: `docs/superpowers/specs/2026-06-03-economy-nic-flow-design.md` +Implementation plan: `docs/superpowers/plans/2026-06-03-economy-nic-flow.md` +Branch: p36.5 (commits 9a3a1b2 → a147494) + +### Notes +- `SiegeFee(37)`, `SiegeFeeRefund(38)`, and `SiegePoolPayback(41)` are unclassified — siege subsystem appears dormant; add to appropriate categories when siege activity resumes. +- `transactiondate` uses `getdate()` (local server time); queries compare against `GETUTCDATE()`. Accurate as long as SQL Server runs in UTC (standard deployment). +- Cross-reference IMPROVEMENT-035 — this panel provides the operator visibility prerequisite for revisiting player order signal in AutoMarket pricing. + +--- ## IMPROVEMENT-002 - Refactor Hardcoded System Characters and Channels @@ -603,3 +831,71 @@ Pricing Trace data source: query the last computed values from `resource_market_ Implemented via plan `docs/superpowers/plans/2026-05-28-automarket-admintool.md` (14 tasks, branch p36.4). Refresh Now calls SPs directly from AdminTool DB connection (no server-side handler needed). `{x:Static}` binding on source-generator types causes MC1000 BAML errors — worked around with instance forwarder properties on `AutoMarketOrdersViewModel`. + +--- + +## IMPROVEMENT-032 - Export: Generate Full SQL Scripts for Seasons, Items, and Robots + +Status: DONE +Priority: MEDIUM +Area: Admin Tool / Content / Tooling + +### Description + +Add an **Export** feature to the Admin Tool that generates a complete, self-contained SQL script for a selected entity — a season, an item definition, or a robot definition. The script must capture all dependent data (definitions, extensions, tech tree nodes, effects, module assignments, crafting recipes, etc.) so it can be replayed on a clean database to recreate the entity from scratch. + +### Impact + +Currently there is no way to extract a game entity as portable SQL. Transferring content between server instances, creating backups of handcrafted entities, or sharing content with other operators requires direct DB access and manual query construction. An export tool reduces this friction significantly and acts as a lightweight content migration mechanism. + +### Proposed Implementation + +- **Export targets:** Season (full chain: season record, activities, objectives, reward packages, reward items), Item definition (entitydefaults row, extensions, aggregate fields, tech tree nodes, crafting recipe, market config), Robot definition (entitydefaults row, chassis slots, head/leg/chassis component links, extensions, tech tree nodes). +- **Output format:** Idempotent SQL script using `MERGE` / `IF NOT EXISTS` / `DELETE + INSERT` patterns consistent with the existing content pipeline (see `docs/content/claude_game_content_guide.md`). Scripts must be replayable without manual ID editing — resolve foreign keys dynamically by name where possible, or embed explicit ID resolution CTEs. +- **UI surface:** Export button/menu entry in each relevant Admin Tool panel (Seasons panel, item editor, robot editor). Opens a dialog showing the generated script with a Copy and a Save As option. +- **Scope boundary:** Export is read-only and generates SQL text only — it does not execute the script or modify any data. + +### Notes + +- Never hardcode definition or extension IDs in generated output — resolve via `entitydefaults`/`extensions` name lookups exactly as the manual content guide mandates. +- The generated script should include a header comment identifying the export source, entity name, and export timestamp. +- Consult `docs/content/claude_game_content_guide.md` sections 2 and 24 for dependency order before implementing the traversal logic. +- Consider a shared `SqlExportBuilder` utility class to avoid duplicating script-generation logic across the three entity types. + +--- + +## IMPROVEMENT-033 - Equipment Set Rewards for Seasons + +Status: DONE +Priority: HIGH +Area: Seasons / Rewards + +### Description + +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 a reward option. When a reward of this type is granted, the player receives one randomly selected item from the named equipment set instead of a fixed item. + +### Impact + +Tier rewards, objective rewards, and leaderboard rewards currently support only fixed item grants. Equipment set rewards add designer-controlled randomness: a player is guaranteed an item from a curated pool (a themed set) but does not know which piece they will receive. This increases perceived value, supports set-collection engagement loops, and reduces designer overhead by allowing one reward entry to cover an entire set rather than requiring individual item reward rows. + +### Proposed Implementation + +**Data layer:** +- Extend the reward package schema to include an optional `equipment_set_id` column (FK to `equipment_sets`) alongside the existing item definition reference. Exactly one of `item_definition_id` or `equipment_set_id` should be non-null per reward row. +- On reward grant, if `equipment_set_id` is set: query all module definitions belonging to that set, select one at random, and grant that item via the standard item grant pipeline. +- If the equipment set has no members at grant time, log a warning and skip the reward (no crash, no silent data corruption). + +**Server runtime:** +- Extend the reward grant path (shared by tier, objective, and leaderboard rewards) to handle the `equipment_set_id` case — keep the branching in the reward delivery layer, not scattered across each reward trigger site. +- Random selection should be uniform across all set members unless a weighted variant is later requested. + +**Admin Tool:** +- In the reward package editor (used by tier rewards, objective rewards, and leaderboard rewards), add an "Equipment Set" reward type option alongside the existing item picker. +- When "Equipment Set" is selected, show a dropdown of defined equipment sets; hide the item definition picker. + +### Notes + +- Reuse the equipment set membership data already introduced by IMPROVEMENT-025 (`equipment_sets` / module-to-set assignment) — do not introduce a parallel set definition mechanism. +- Consult `docs/content/claude_game_content_guide.md` for reward package SQL patterns before generating migration SQL. +- Validate that the selected set has at least one member before saving in the Admin Tool (warn, do not hard-block). +- Random selection occurs at grant time on the server, not at reward package definition time. diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index 0738be96..97681892 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -1,6 +1,181 @@ # Last ID used -024 +029 + +## ISSUE-029 - Insurance price recalculation crashes with SP nesting level exceeded (limit 32) + +Status: DONE +Priority: CRITICAL +Area: Economy / Insurance + +### Problem +On production, calling `usp_RecalculateInsurancePrices` throws: + +> Maximum stored procedure, function, trigger, or view nesting level exceeded (limit 32) + +The recalculation fails entirely; insurance prices are not updated. + +### Root Cause (Confirmed) +Both `v_all_production_costs` and `v_required_raw_materials` contain recursive CTEs whose recursive +member JOINs against `production_data`, which is a VIEW (not a base table). SQL Server increments +the view nesting counter on every recursive iteration that references an external view. On production +data with crafting chains deeper than ~28 items the counter exceeds the 32-level limit. Locally, +sparse data means chains rarely exceed 3–5 levels, so the bug never triggers. + +`usp_RecalculateInsurancePrices` executes `v_all_production_costs` inline inside a MERGE statement, +which exposes the per-iteration view nesting accumulation. `usp_RefreshAutoMarketOrders` is +unaffected because it materializes the same views into temp tables via a standalone SELECT, where +the optimizer handles the recursive CTE differently. + +### Fix +Inlined `production_data` as a local CTE (`prod_data`) at the top of both recursive views. +A CTE reference inside a recursive member does not increment the view nesting counter. +Semantics are identical (same filter, same columns). + +### Files Changed +- `docs/db_structure/views/v_all_production_costs.sql` +- `docs/db_structure/views/v_required_raw_materials.sql` +- `docs/db_structure/migrations/ISSUE-029-fix-view-nesting-in-recursive-cost-views.sql` + +### Notes +- Migration can be applied while the server is running (`CREATE OR ALTER VIEW` is non-blocking). +- After applying, uncomment and run `EXEC dbo.usp_RecalculateInsurancePrices` to verify. + +--- + +## ISSUE-028 - AdminTool AutoMarket: buyback orders not removed after deleting item from trade list + +Status: DONE +Priority: CRITICAL +Area: AdminTool / AutoMarket + +### Problem +After deleting an item from the AutoMarket trade list and running "Refresh Now", sell orders for that item were removed correctly but buy (buyback) orders remained on the market. + +### Root Cause +Step 0 of `usp_RefreshAutoMarketOrders` snapshots "unbought resources" using `NOT EXISTS (SELECT 1 FROM market_orders_configuration)` to skip production-item buyback orders. When an item is deleted from `market_orders_configuration` before the SP runs, this check passes for its buyback order — the order is captured into `automarket_unbought_resources` as if it were an unfulfilled raw-material buy order. Step 1 deletes all auto orders, but Step 4 then re-inserts a new buy order for the deleted item from the `Unbought` carry-over, because the item still has a production cost in `v_all_production_costs`. + +### Fix +In Step 0's `automarket_unbought_resources` insert, replaced: +```sql +AND NOT EXISTS (SELECT 1 FROM market_orders_configuration moc WHERE moc.definitionname = ed.definitionname) +``` +with: +```sql +AND NOT EXISTS (SELECT 1 FROM production_data pd_check WHERE pd_check.product = ed.definitionname) +``` +This classifies items by whether they can be manufactured (stable) rather than whether they are currently in the trade list (breaks on deletion). + +### Files Changed +- `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` + +--- + +## ISSUE-027 - Sell orders at matching prices do not auto-fulfill against open buy orders + +Status: DONE +Priority: CRITICAL +Area: Market / Trading + +### Problem +Players report that creating a sell order at a price equal to or below an existing open buy order does not result in an automatic trade. The sell order is posted as a standing order rather than immediately matching and settling against the best available buy order. + +### Impact +Market trades do not settle when they should. Players placing competitive sell orders experience no fulfillment despite valid counterpart buy orders existing, breaking the fundamental market matching expectation and potentially trapping capital in open orders. + +### Root Cause +The matching condition in both `MarketCreateSellOrder` and `MarketCreateBuyOrder` was: +```csharp +if (!forMyCorporation && highestBuyOrder != null) +``` +This condition completely skips automatic matching whenever the player marks their order as corporation-only (`forMyCorporation = true`), even when a matching corp-only order from the same corporation exists. Players in player corporations are the primary affected group. + +Additionally, `GetHighestBuyOrder` had a minor inconsistency: the SQL column reference used `@itemDefinition` (capital D) while `SetParameter` used `@itemdefinition` (lowercase d) — and similarly `submitterEID` vs `submittereid`. These are harmless with SqlClient's case-insensitive parameter matching but were corrected for consistency. + +### Fix +- `MarketCreateSellOrder.HandleRequest`: Changed condition to `highestBuyOrder != null && (!forMyCorporation || highestBuyOrder.forMembersOf == forMembersOf)` — allows corp-only sells to match against corp buy orders from the same corp, while still blocking corp sells against public buy orders. +- `MarketCreateBuyOrder.HandleRequest`: Same symmetric fix for `lowestSellOrder`. +- `MarketOrderRepository.GetHighestBuyOrder`: Normalized SQL column/parameter names to lowercase for consistency with `GetLowestSellOrder`. + +--- + +## ISSUE-026 - AdminTool AutoMarket Orders filters not working as expected + +Status: TODO +Priority: MEDIUM +Area: Admin Tool / AutoMarket + +### Problem +Three distinct filter bugs on the AutoMarket → Orders view in the Admin Tool: + +1. **Order type filter returns no results** — selecting a buy or sell order type filter produces an empty list regardless of actual order volume. Likely a binding or query mismatch between the selected enum/value and what the server-side filter expects. +2. **Category filter excludes child categories** — filtering by a parent category only returns items assigned directly to that category; items in sub-categories are excluded. The filter needs to match the selected category and all of its descendants. +3. **No way to reset filters** — once a filter is applied, there is no reset or clear button. Users must restart or navigate away to return to the unfiltered list. + +### Impact +Operators cannot meaningfully browse or audit market orders. The broken type and category filters make it impractical to find specific orders; the lack of reset compounds the friction by trapping users in a filtered state. + +### Proposed Fix +1. **Order type filter** — trace the selected value from the UI dropdown through the ViewModel command to the server query. Verify the filter value is correctly mapped to the DB column type and that the query predicate is applied (not silently dropped). +2. **Category filter** — replace the direct category equality check with a recursive or closure-based lookup that resolves all descendant category IDs for the selected node and filters on the full set (e.g. via a recursive CTE or a pre-loaded category tree walk). +3. **Reset filters** — add a "Clear Filters" button (or equivalent reset action) to the Orders view that restores all filter fields to their default/unset state and reloads the full order list. + +### Notes +- Investigate whether the type filter bug is a null/default value mismatch (e.g. enum default being passed as the filter even when "All" is selected, or vice versa). +- The category tree hierarchy is likely already used elsewhere in the Admin Tool or game content — reuse the existing resolution pattern rather than introducing a new one. +- Fix all three as a single unit since they share the same view; shipping a partial fix leaves the Orders filter UX still broken. + +--- + +## ISSUE-025 - Top leaderboard participants did not receive rewards after Active Season ended + +Status: IN_PROGRESS +Priority: CRITICAL +Area: Seasons / Rewards / Leaderboard + +### Problem +After "Seasons, oh May!" (end_time 2026-06-01T03:00:00) concluded, top leaderboard participants received no rewards. Root cause confirmed: data configuration error. + +### Root Cause (Confirmed) +All 3 `season_leaderboard_rewards` rows have `rank_min > rank_max` (swapped fields): + +| rank_min | rank_max | Package | Intended | +|---|---|---|---| +| 3 | 1 | Syndicate_Season1_Leadership1 | min=1, max=3 | +| 6 | 4 | Syndicate_Season1_Leadership2 | min=4, max=6 | +| 10 | 7 | Syndicate_Season1_Leadership3 | min=7, max=10 | + +Server matching (`SeasonService.cs:399`): `rank >= r.RankMin && rank <= r.RankMax` — impossible to satisfy when min > max. Rewards were never delivered. + +Compounded by `MarkLeaderboardDelivered` being called unconditionally (`SeasonService.cs:403`) even when no reward matched. All participants have `leaderboard_reward_delivered = 1`, blocking any automatic re-run. + +### Fix + +**Operator must apply immediately (SQL):** +```sql +-- Reset delivered flag +UPDATE season_character_points +SET leaderboard_reward_delivered = 0 +WHERE season_id = (SELECT id FROM seasons WHERE name = N'Seasons, oh May!'); + +-- Fix swapped rank ranges +UPDATE season_leaderboard_rewards SET rank_min=1, rank_max=3 +WHERE season_id=(SELECT id FROM seasons WHERE name=N'Seasons, oh May!') AND rank_min=3 AND rank_max=1; +UPDATE season_leaderboard_rewards SET rank_min=4, rank_max=6 +WHERE season_id=(SELECT id FROM seasons WHERE name=N'Seasons, oh May!') AND rank_min=6 AND rank_max=4; +UPDATE season_leaderboard_rewards SET rank_min=7, rank_max=10 +WHERE season_id=(SELECT id FROM seasons WHERE name=N'Seasons, oh May!') AND rank_min=10 AND rank_max=7; +``` + +**Code changes required:** +1. New `SeasonRedeliverLeaderboardRewards` admin request handler — re-runs reward delivery for a past ended season by ID, respecting the `leaderboard_reward_delivered` flag. +2. Admin Tool validation in `SeasonDetailViewModel.QueueSaveLeaderboardReward` — guard `rank_min ≤ rank_max` before queuing the save. + +### Notes +- `DeliverLeaderboardReward` writes to the redeemable items table via `InsertRedeemableItems` — no server restart needed once the command exists. +- The re-deliver command must load leaderboard reward rows directly from the DB (not the in-memory cache, which is cleared at season end). + +--- ## ISSUE-024 - AutoMarket pricing structurally excludes player crafters from the production economy diff --git a/docs/db_structure/migrations/IMPROVEMENT-036-insurance-overhaul.sql b/docs/db_structure/migrations/IMPROVEMENT-036-insurance-overhaul.sql new file mode 100644 index 00000000..7ea1d8ee --- /dev/null +++ b/docs/db_structure/migrations/IMPROVEMENT-036-insurance-overhaul.sql @@ -0,0 +1,57 @@ +-- IMPROVEMENT-036: Insurance System Overhaul +-- Apply once to the live database while the server is OFFLINE, before deploying the new build. +-- Run in order: table → procedure → clear stale policies → initial price population. + +-- 1. Create insurance_config table +IF OBJECT_ID('dbo.insurance_config', 'U') IS NULL +BEGIN + CREATE TABLE dbo.insurance_config ( + param_name NVARCHAR(64) NOT NULL PRIMARY KEY, + param_value FLOAT NOT NULL + ); + INSERT INTO dbo.insurance_config (param_name, param_value) VALUES + ('fee_pct', 0.10), + ('payout_pct', 0.08); +END + +-- 2. Create usp_RecalculateInsurancePrices +CREATE OR ALTER PROCEDURE dbo.usp_RecalculateInsurancePrices AS +BEGIN + SET NOCOUNT ON; + + DECLARE @fee_pct FLOAT = (SELECT param_value FROM dbo.insurance_config WHERE param_name = 'fee_pct'); + DECLARE @payout_pct FLOAT = (SELECT param_value FROM dbo.insurance_config WHERE param_name = 'payout_pct'); + + IF @fee_pct IS NULL OR @payout_pct IS NULL + BEGIN + RAISERROR('insurance_config: fee_pct and payout_pct must both be set.', 16, 1); + RETURN; + END + + IF @payout_pct >= @fee_pct + BEGIN + RAISERROR('insurance_config: payout_pct must be strictly less than fee_pct to keep insurance a NIC sink.', 16, 1); + RETURN; + END + + MERGE dbo.insuranceprices AS t + USING ( + SELECT + ed.definition, + ROUND(vpc.production_cost_nic * @fee_pct, 0) AS fee, + ROUND(vpc.production_cost_nic * @payout_pct, 0) AS payout + FROM dbo.v_all_production_costs vpc + JOIN dbo.entitydefaults ed + ON ed.definitionname = vpc.product COLLATE DATABASE_DEFAULT + WHERE ed.definition IN (SELECT definition FROM dbo.insuranceprices) + AND vpc.production_cost_nic > 0 + ) AS s ON t.definition = s.definition + WHEN MATCHED THEN + UPDATE SET t.fee = s.fee, t.payout = s.payout; +END + +-- 3. Clear all stale insurance policies (payout values are outdated; players repurchase at new rates) +DELETE FROM dbo.insurance; + +-- 4. Populate insuranceprices immediately so the server cache loads correct values on first startup +EXEC dbo.usp_RecalculateInsurancePrices; diff --git a/docs/db_structure/migrations/IMPROVEMENT-039-economy-health.sql b/docs/db_structure/migrations/IMPROVEMENT-039-economy-health.sql new file mode 100644 index 00000000..713d1d6f --- /dev/null +++ b/docs/db_structure/migrations/IMPROVEMENT-039-economy-health.sql @@ -0,0 +1,34 @@ +-- IMPROVEMENT-039: Economy Health Statistics +-- Apply once to the live database before deploying the matching server and Admin Tool builds. +-- Tables: run once only (will error if tables already exist — that is intentional). +-- Procedure: CREATE OR ALTER — safe to re-run. + +CREATE TABLE economy_daily_snapshot ( + id INT IDENTITY(1,1) PRIMARY KEY, + snapshot_date DATE NOT NULL, + total_nic BIGINT NOT NULL, + CONSTRAINT UQ_economy_daily_snapshot_date UNIQUE (snapshot_date) +); + +CREATE TABLE economy_price_index_basket ( + id INT IDENTITY(1,1) PRIMARY KEY, + definition INT NOT NULL, + weight DECIMAL(5,2) NOT NULL DEFAULT 1.0 +); + +CREATE OR ALTER PROCEDURE usp_RecordEconomySnapshot AS +BEGIN + DECLARE @snapshot_date DATE = CAST(GETUTCDATE() AS DATE); + DECLARE @total_nic BIGINT = + ISNULL((SELECT SUM(CAST(credit AS BIGINT)) FROM characters + WHERE active = 1 AND deletedAt IS NULL), 0) + + ISNULL((SELECT SUM(CAST(wallet AS BIGINT)) FROM corporations + WHERE active = 1 AND defaultcorp = 0), 0); + + MERGE economy_daily_snapshot AS t + USING (SELECT @snapshot_date AS snapshot_date, @total_nic AS total_nic) AS s + ON t.snapshot_date = s.snapshot_date + WHEN MATCHED THEN UPDATE SET total_nic = s.total_nic + WHEN NOT MATCHED THEN INSERT (snapshot_date, total_nic) + VALUES (s.snapshot_date, s.total_nic); +END diff --git a/docs/db_structure/migrations/ISSUE-029-fix-view-nesting-in-recursive-cost-views.sql b/docs/db_structure/migrations/ISSUE-029-fix-view-nesting-in-recursive-cost-views.sql new file mode 100644 index 00000000..cee07cca --- /dev/null +++ b/docs/db_structure/migrations/ISSUE-029-fix-view-nesting-in-recursive-cost-views.sql @@ -0,0 +1,148 @@ +-- ISSUE-029: Fix nesting level exceeded in usp_RecalculateInsurancePrices +-- +-- Root cause: both v_all_production_costs and v_required_raw_materials contain recursive CTEs +-- whose recursive member JOINs against production_data (a VIEW). SQL Server increments the +-- view nesting counter on each recursive iteration that references an external view. On +-- production data with crafting chains deeper than ~28 levels this exceeds the 32-level limit, +-- causing: "Maximum stored procedure, function, trigger, or view nesting level exceeded (limit 32)". +-- The same chains terminate quickly on sparse local data, so the bug does not reproduce locally. +-- +-- Fix: inline production_data logic as a local CTE (prod_data) at the top of each affected view. +-- A CTE reference inside a recursive member does not increment the view nesting counter. +-- Semantics are identical: same filter (purchasable=1, enabled=1, hidden=0), same columns. +-- +-- Apply while the server is RUNNING (views are schema-bound read-only objects; no downtime needed). +-- Both views are hot-swapped atomically by CREATE OR ALTER VIEW. + +-- 1. Fix v_required_raw_materials first (it is referenced by v_all_production_costs) +CREATE OR ALTER VIEW [dbo].[v_required_raw_materials] AS + WITH prod_data AS ( + SELECT + ed.definitionname AS product, + ced.definitionname AS components, + c.componentamount AS amount + FROM dbo.components c + INNER JOIN dbo.entitydefaults ed ON c.definition = ed.definition + INNER JOIN dbo.entitydefaults ced ON c.componentdefinition = ced.definition + WHERE ed.purchasable = 1 AND ed.enabled = 1 AND ed.hidden = 0 + ), + RecursiveBreakdown AS ( + -- Base case: direct components + SELECT + moc.definitionname AS product, + pd.components AS component, + SUM(CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT)) AS total_amount + FROM dbo.market_orders_configuration moc + JOIN prod_data pd ON moc.definitionname = pd.product + GROUP BY moc.definitionname, pd.components + + UNION ALL + + -- Recursive case: break down intermediate components + SELECT + rb.product, + pd.components AS component, + rb.total_amount * CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT) AS total_amount + FROM RecursiveBreakdown rb + JOIN prod_data pd ON rb.component = pd.product + ) + SELECT + rb.product AS product, + rb.component AS raw_material, + SUM(rb.total_amount) AS total_quantity + FROM RecursiveBreakdown rb + LEFT JOIN prod_data pd ON rb.component = pd.product + WHERE pd.product IS NULL + GROUP BY rb.product, rb.component; +GO + +-- 2. Fix v_all_production_costs +CREATE OR ALTER VIEW [dbo].[v_all_production_costs] AS +WITH prod_data AS ( + SELECT + ed.definitionname AS product, + ced.definitionname AS components, + CAST(c.componentamount AS FLOAT) AS amount + FROM dbo.components c + INNER JOIN dbo.entitydefaults ed ON c.definition = ed.definition + INNER JOIN dbo.entitydefaults ced ON c.componentdefinition = ced.definition + WHERE ed.purchasable = 1 AND ed.enabled = 1 AND ed.hidden = 0 +), +all_items AS ( + SELECT product AS item FROM prod_data + UNION + SELECT components AS item FROM prod_data +), +recursive_materials AS ( + SELECT + base.item, + pd.components AS raw_material, + CAST(pd.amount * 2.1 AS FLOAT) AS quantity + FROM all_items base + JOIN prod_data pd ON pd.product = base.item + + UNION ALL + + SELECT + rm.item, + pd.components AS raw_material, + rm.quantity * pd.amount * 2.1 AS quantity + FROM recursive_materials rm + JOIN prod_data pd ON rm.raw_material = pd.product +), +aggregated_costs AS ( + SELECT + rm.item AS product, + rm.raw_material, + SUM(rm.quantity) AS total_quantity + FROM recursive_materials rm + GROUP BY rm.item, rm.raw_material +), +latest_market_prices AS ( + SELECT rmp.resource_name, rmp.unit_price + FROM resource_market_prices rmp + WHERE rmp.calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) +), +max_scarcity_price AS ( + SELECT TOP 1 + cdp.dynamic_price + * (SELECT param_value FROM automarket_config WHERE param_name = 'plasma_anchor_fraction') + * (SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_max') + * 2.0 AS price + FROM fn_CalculateDynamicPlasmaPrices(1) cdp + WHERE cdp.plasma_type = 'def_common_reactor_plasma' +), +computed_costs AS ( + SELECT + ac.product, + SUM( + ac.total_quantity * ISNULL(mp.unit_price, msp.price) + ) AS production_cost_nic + FROM aggregated_costs ac + LEFT JOIN latest_market_prices mp + ON ac.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp + GROUP BY ac.product +), +raw_resources AS ( + SELECT + base.raw_material AS product, + ISNULL(mp.unit_price, msp.price) AS production_cost_nic + FROM (SELECT DISTINCT raw_material FROM v_required_raw_materials) base + LEFT JOIN latest_market_prices mp + ON base.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp +), +final_costs AS ( + SELECT * FROM computed_costs + UNION + SELECT * FROM raw_resources +) +SELECT + product, + ROUND(production_cost_nic, 2) AS production_cost_nic +FROM final_costs; +GO + +-- 3. Verify: run usp_RecalculateInsurancePrices to confirm no nesting error +-- EXEC dbo.usp_RecalculateInsurancePrices; diff --git a/docs/db_structure/stored_procedures/dbo.usp_RecalculateInsurancePrices.sql b/docs/db_structure/stored_procedures/dbo.usp_RecalculateInsurancePrices.sql new file mode 100644 index 00000000..7dc8dd8b --- /dev/null +++ b/docs/db_structure/stored_procedures/dbo.usp_RecalculateInsurancePrices.sql @@ -0,0 +1,36 @@ +CREATE OR ALTER PROCEDURE dbo.usp_RecalculateInsurancePrices AS +BEGIN + SET NOCOUNT ON; + + DECLARE @fee_pct FLOAT = (SELECT param_value FROM dbo.insurance_config WHERE param_name = 'fee_pct'); + DECLARE @payout_pct FLOAT = (SELECT param_value FROM dbo.insurance_config WHERE param_name = 'payout_pct'); + + IF @fee_pct IS NULL OR @payout_pct IS NULL + BEGIN + RAISERROR('insurance_config: fee_pct and payout_pct must both be set.', 16, 1); + RETURN; + END + +/* + IF @payout_pct >= @fee_pct + BEGIN + RAISERROR('insurance_config: payout_pct must be strictly less than fee_pct to keep insurance a NIC sink.', 16, 1); + RETURN; + END +*/ + + MERGE dbo.insuranceprices AS t + USING ( + SELECT + ed.definition, + ROUND(vpc.production_cost_nic * @fee_pct, 0) AS fee, + ROUND(vpc.production_cost_nic * @payout_pct, 0) AS payout + FROM dbo.v_all_production_costs vpc + JOIN dbo.entitydefaults ed + ON ed.definitionname = vpc.product COLLATE DATABASE_DEFAULT + WHERE ed.definition IN (SELECT definition FROM dbo.insuranceprices) + AND vpc.production_cost_nic > 0 + ) AS s ON t.definition = s.definition + WHEN MATCHED THEN + UPDATE SET t.fee = s.fee, t.payout = s.payout; +END \ No newline at end of file diff --git a/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql index 1ae53d52..b1a648cc 100644 --- a/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql +++ b/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql @@ -31,7 +31,10 @@ BEGIN WHERE isAutoOrder = 1 AND isSell = 1 GROUP BY itemdefinition; - -- Unbought mats: exclude plasma (3271-3274) AND production items (market_orders_configuration) + -- Unbought mats: exclude plasma (3271-3274) and any item that can be manufactured + -- (production_data.product). Using market_orders_configuration here would incorrectly + -- capture buyback orders for items just removed from the trade list, causing Step 4 + -- to re-place a buy order for them as if they were raw materials. INSERT INTO automarket_unbought_resources (itemdefinition, quantity) SELECT mi.itemdefinition, SUM(CAST(mi.quantity AS BIGINT)) FROM marketitems mi @@ -39,8 +42,8 @@ BEGIN WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 AND mi.itemdefinition NOT IN (3271, 3272, 3273, 3274) AND NOT EXISTS ( - SELECT 1 FROM market_orders_configuration moc - WHERE moc.definitionname = ed.definitionname + SELECT 1 FROM production_data pd_check + WHERE pd_check.product = ed.definitionname ) GROUP BY mi.itemdefinition; diff --git a/docs/db_structure/views/v_all_production_costs.sql b/docs/db_structure/views/v_all_production_costs.sql index 8bb3b484..2e3bab65 100644 --- a/docs/db_structure/views/v_all_production_costs.sql +++ b/docs/db_structure/views/v_all_production_costs.sql @@ -7,12 +7,25 @@ GO ---- Use dynamic resource_market_prices; fallback is max-scarcity formula (no raw_material_prices dependency) +---- prod_data inlines production_data to avoid view-nesting-level accumulation inside the recursive member. +---- SQL Server increments the view nesting counter on every recursive iteration that references an external +---- view; a CTE reference does not count, so chains deeper than ~28 levels no longer hit the 32-level limit. -CREATE VIEW [dbo].[v_all_production_costs] AS -WITH all_items AS ( - SELECT product AS item FROM production_data +CREATE OR ALTER VIEW [dbo].[v_all_production_costs] AS +WITH prod_data AS ( + SELECT + ed.definitionname AS product, + ced.definitionname AS components, + CAST(c.componentamount AS FLOAT) AS amount + FROM dbo.components c + INNER JOIN dbo.entitydefaults ed ON c.definition = ed.definition + INNER JOIN dbo.entitydefaults ced ON c.componentdefinition = ced.definition + WHERE ed.purchasable = 1 AND ed.enabled = 1 AND ed.hidden = 0 +), +all_items AS ( + SELECT product AS item FROM prod_data UNION - SELECT components AS item FROM production_data + SELECT components AS item FROM prod_data ), recursive_materials AS ( SELECT @@ -20,7 +33,7 @@ recursive_materials AS ( pd.components AS raw_material, CAST(pd.amount * 2.1 AS FLOAT) AS quantity FROM all_items base - JOIN production_data pd ON pd.product = base.item + JOIN prod_data pd ON pd.product = base.item UNION ALL @@ -29,7 +42,7 @@ recursive_materials AS ( pd.components AS raw_material, rm.quantity * pd.amount * 2.1 AS quantity FROM recursive_materials rm - JOIN production_data pd ON rm.raw_material = pd.product + JOIN prod_data pd ON rm.raw_material = pd.product ), aggregated_costs AS ( SELECT diff --git a/docs/db_structure/views/v_required_raw_materials.sql b/docs/db_structure/views/v_required_raw_materials.sql index 1c9b0443..d4a9a5ae 100644 --- a/docs/db_structure/views/v_required_raw_materials.sql +++ b/docs/db_structure/views/v_required_raw_materials.sql @@ -6,29 +6,39 @@ SET QUOTED_IDENTIFIER ON GO - - --- Create the view -CREATE VIEW [dbo].[v_required_raw_materials] AS - WITH RecursiveBreakdown AS ( +-- prod_data inlines production_data to avoid view-nesting-level accumulation inside the recursive member. +-- SQL Server increments the view nesting counter on every recursive iteration that references an external +-- view; a CTE reference does not count, so chains deeper than ~28 levels no longer hit the 32-level limit. +CREATE OR ALTER VIEW [dbo].[v_required_raw_materials] AS + WITH prod_data AS ( + SELECT + ed.definitionname AS product, + ced.definitionname AS components, + c.componentamount AS amount + FROM dbo.components c + INNER JOIN dbo.entitydefaults ed ON c.definition = ed.definition + INNER JOIN dbo.entitydefaults ced ON c.componentdefinition = ced.definition + WHERE ed.purchasable = 1 AND ed.enabled = 1 AND ed.hidden = 0 + ), + RecursiveBreakdown AS ( -- Base case: direct components - SELECT + SELECT moc.definitionname AS product, pd.components AS component, SUM(CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT)) AS total_amount -- 50% efficiency adjustment FROM dbo.market_orders_configuration moc - JOIN dbo.production_data pd ON moc.definitionname = pd.product + JOIN prod_data pd ON moc.definitionname = pd.product GROUP BY moc.definitionname, pd.components UNION ALL -- Recursive case: break down intermediate components - SELECT + SELECT rb.product, pd.components AS component, rb.total_amount * CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT) AS total_amount FROM RecursiveBreakdown rb - JOIN dbo.production_data pd ON rb.component = pd.product + JOIN prod_data pd ON rb.component = pd.product ) -- Final aggregation: only raw materials (not further craftable) @@ -37,8 +47,8 @@ CREATE VIEW [dbo].[v_required_raw_materials] AS rb.component AS raw_material, SUM(rb.total_amount) AS total_quantity FROM RecursiveBreakdown rb - LEFT JOIN dbo.production_data pd ON rb.component = pd.product + LEFT JOIN prod_data pd ON rb.component = pd.product WHERE pd.product IS NULL GROUP BY rb.product, rb.component; -GO \ No newline at end of file +GO 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 + + + +