diff --git a/.gitignore b/.gitignore index 5238b6c..4089f68 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Releases/ # graphify-dotnet generated output (regenerates on build) docs/graph/ graphify-out/ + +# Claude Code worktrees +.claude/worktrees/ diff --git a/CHANGELOG-p36.3.txt b/CHANGELOG-p36.3.txt new file mode 100644 index 0000000..543116d --- /dev/null +++ b/CHANGELOG-p36.3.txt @@ -0,0 +1,74 @@ +Perpetuum Server 2 — Patch p36.3 Changelog +========================================== +Date: 2026-05-24 + + +NEW FEATURES +------------ + +Equipment Set Bonuses (IMPROVEMENT-025) + Pilots now receive automatic stat bonuses when equipping multiple items + belonging to the same named equipment set (e.g. set_striker). + + - New database schema: equipmentsets and equipmentsetbonuses tables store + set definitions and their per-piece bonus thresholds. + - New effect type: effect_equipment_set_bonus — a dedicated effect category + that carries set bonus modifiers so the client can display them correctly. + - EquipmentSetRepository loads set data from the database on startup and + keeps it in an in-memory cache for fast zone-side access. + - EquipmentSetBonusCalculator evaluates which sets are active on a robot + (enough equipped pieces to qualify for each bonus tier) and returns the + resulting modifier stack. + - SetBonusEffectApplicator runs on every Robot.OnUpdate tick: it compares + the current active sets to what is already applied, removes stale effects, + and adds new ones — ensuring the effect list stays in sync with the + robot's loadout at all times. + - Robot stat pipeline extended to include set bonus modifiers alongside + existing armor, shield, and module modifiers. + - Pilot set content SQL added for the Striker set (set_striker). + +Equipment Set Bonus Effect Display (IMPROVEMENT-027) + The set bonus effect shown in the client's effect list now embeds the + actual numeric modifier values instead of showing a generic label. + + - EquipmentSetBonusResult now carries the full list of EffectModifier + entries produced by the active set bonuses. + - SetBonusEffectApplicator builds a human-readable description from those + modifiers (e.g. "+5% armor max, +3% weapon cycle time") and injects it + into the effect display name. + - Robot.OnUpdate updated to pass the modifier data through to the applicator. + +Daily Objectives Announcement — Extended Format (unlabelled) + The server's daily objectives broadcast now includes each objective's + description text alongside its name, giving pilots more context about what + to do without having to open the objectives panel. + + Before: " — Destroy 10 NPC units" + After: " — Destroy 10 NPC units: Eliminate NPC robots anywhere on Nia." + +Daily Objectives on Cold Boot (IMPROVEMENT-024) + The server now announces today's daily objective pool to the season channel + automatically when it starts up, so pilots who log in after a restart are + not left in the dark. + +Today's Daily Objectives in Admin Tool (IMPROVEMENT-024) + The Admin Tool Statistics tab gains a "Today's Daily Objectives" section + that lists the current pool with activity type, required quantity, and + bonus season point value. Data is loaded asynchronously from the database. + + +BUG FIXES +--------- + +Market NIC Tracking Missing for Direct Purchases (ISSUE-020) + Direct market purchases handled by MarketBuyItem (buy-now from a sell + order) were not recording NicSpent or NicEarned season activity, so those + trades were invisible to the season tracking system. The bug affected all + three purchase branches: + - Player-to-player sell orders + - Vendor orders with infinite stock + - Vendor orders with finite stock + Matching RecordActivity calls have been added to each branch, consistent + with the existing hooks in FulfillBuyOrderInstantly. + + diff --git a/CLAUDE.md b/CLAUDE.md index 0b5d82c..a44a83c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ For any non-trivial task: 4. Check `docs/graph/GRAPH_REPORT.md` for God Nodes (high-risk symbols); run `.\tools\query-graph.ps1 -Direction in` to enumerate direct dependents — a null result is normal (most classes have no detected importers) and does not mean the change is safe (if `graph.json` is absent, skip and continue to step 5) 5. Understand existing patterns 6. Evaluate runtime implications -7. Produce a short implementation plan +7. Produce a short implementation plan — for any task that modifies an interface or a widely-used class, the plan must include an explicit step to run `.\tools\query-graph.ps1 -Direction in` before touching that file 8. Then implement --- diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index 20d32e0..a88e4cf 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -1,6 +1,6 @@ # Last ID used -029 +031 ## IMPROVEMENT-002 - Refactor Hardcoded System Characters and Channels @@ -416,7 +416,7 @@ Deleting a set should warn if modules are still assigned to it. ## IMPROVEMENT-029 - Pin Daily Activity Announcements in Discord -Status: TODO +Status: DONE Priority: HIGH Area: Seasons / Announcements / Discord Integration @@ -442,3 +442,164 @@ When a daily activity announcement is dispatched to the integrated Discord chann Requires the Discord bot/webhook integration to have the `Manage Messages` permission in the target channel. If the current integration uses an incoming webhook rather than a bot token, pinning is not possible via webhooks — a bot token with the `Manage Messages` permission will be required. Assess the current integration type before implementing. The last pinned message ID can be stored in memory across restarts only if a restart always re-announces; otherwise persist it (a single-row config table or a flat file entry is sufficient). + +--- + +## IMPROVEMENT-030 - AutoMarket Overhaul: NIC Injection Control, Dynamic Risk-Aware Pricing, and Performance Refactor + +Status: DONE +Priority: HIGH +Area: Economy / AutoMarket / Database + +### Problem + +The AutoMarket has three interconnected problems that together drive hyperinflation: + +1. **Plasma buy orders are a NIC faucet.** Every plasma sale to the bot calls `PayOutToSeller`, which creates NIC from nothing — there is no vendor wallet being drained. The buy quantity equals 100% of all plasma gathered in the past 7 days (`cdp.gathered`), making the bot procyclical: more farming → larger buy orders → more NIC created. No daily spending limit exists. + +2. **Raw material prices are backwards and static.** `recalculate_raw_material_prices` distributes plasma NIC proportionally to gather volume, which means more supply → higher price (opposite of supply/demand). The static `raw_material_prices` fallback table requires manual maintenance and ignores zone risk — alpha and gamma materials are priced identically per the formula. + +3. **Performance and thread-safety concerns.** `usp_RefreshAutoMarketOrders` uses four SQL cursors for order placement (row-by-row, slow). `MarketAutoOrdersManager` fires blocking DB operations synchronously from the process loop. `resources_gathered` lacks zone origin data. + +### Impact + +Inflation continues unchecked while the AutoMarket runs. Raw material prices do not reflect actual gather difficulty or zone risk, making the crafting economy unrealistic. Cursor-based SQL and blocking process-loop operations are latent performance risks. + +### Proposed Fix + +**Part A — NIC Injection Control:** +- New `automarket_config` table for all configurable parameters (anchor fraction, buy quantity fraction, daily budget). +- `usp_RefreshAutoMarketOrders`: multiply plasma buy quantity by `plasma_buy_qty_fraction` (default 0.60); add hard daily NIC budget cap derived from `plasma_sold.income`. +- `MarketAutoOrdersManager`: change refresh interval from 3 days to 1 day. + +**Part B — Zone-Aware Gather Tracking:** +- Add `is_pvp BIT NOT NULL DEFAULT 0` to `resources_gathered_daily` and `resources_gathered`. +- Add `@is_pvp BIT = 0` parameter to `sp_RecordResourceGathered`; update `consolidate_statistics` to preserve it in the merge key. +- Update 5 C# gather call sites (`DrillerModule`, `HarvesterModule`, `LargeDrillerModule`, `LargeHarvesterModule`, `LootContainer`) to pass `!zone.Configuration.Protected`. + +**Part C — Dynamic Risk-Aware Raw Material Pricing:** +- Rewrite `recalculate_raw_material_prices` with a new formula: `price = plasma_anchor × supply_demand_ratio × pvp_risk_multiplier`. Plasma anchor = live alpha plasma price × configurable fraction (default 0.15). Supply/demand ratio clamped 0.25–4.0. Risk multiplier 1.0 (all PvE) to 2.0 (all PvP); ungathered materials default to max scarcity + max risk. +- Remove the `raw_material_prices` fallback from `v_all_production_costs`. The table is deprecated but left in place. + +**Part D — Performance and Thread-Safety Refactoring:** +- Analyze `MarketAutoOrdersManager.Update(time)`: determine process thread ownership; if blocking DB calls on the main process loop are confirmed, offload via `Task.Run` with proper exception handling following existing codebase patterns. +- Replace SQL cursors in `usp_RefreshAutoMarketOrders` with set-based `INSERT ... SELECT` where analysis confirms a performance benefit. Evaluate DELETE-all + INSERT-all vs. MERGE for the order refresh pattern. +- Assess lock contention between frequent `sp_RecordResourceGathered` inserts and `consolidate_statistics` MERGE under load. + +### Implementation Notes + +Completed in branch p36.4. All code changes committed to server runtime. Operator must execute the following SQL DDL against live database before new logic takes effect: + +**Schema changes (Part B):** +1. `ALTER TABLE resources_gathered_daily ADD is_pvp BIT NOT NULL DEFAULT 0` +2. `ALTER TABLE resources_gathered ADD is_pvp BIT NOT NULL DEFAULT 0` + +**Configuration table (Part A):** +3. `CREATE TABLE automarket_config (id INT PRIMARY KEY, plasma_buy_qty_fraction DECIMAL(5,4), daily_nic_budget BIGINT, plasma_anchor_fraction DECIMAL(5,4))` +4. Insert default row: `INSERT INTO automarket_config VALUES (1, 0.60, [calculate from current gather], 0.15)` + +**Stored procedure changes (Parts A, B, C):** +5. `ALTER PROCEDURE sp_RecordResourceGathered` — add `@is_pvp BIT = 0` parameter +6. `ALTER PROCEDURE consolidate_statistics` — add `is_pvp` to GROUP BY and MERGE key +7. `ALTER PROCEDURE recalculate_raw_material_prices` — rewrite with new formula (see design spec) +8. `ALTER PROCEDURE usp_RefreshAutoMarketOrders` — apply budget cap and set-based inserts + +**View changes (Part C):** +9. `ALTER VIEW v_all_production_costs` — remove `raw_material_prices` dependency, use dynamic pricing from procedure + +**Execution notes:** +- Schema changes 1-2 are safe (backward-compatible defaults). +- Execute configuration table creation (3-4) before stored procedure changes. +- Procedures 5-9 must be executed in order: schema → config → procedures → view. +- No data migration required; existing tables and values remain unchanged. +- After DDL execution, refresh server cache (`gameConfig.ConfigManager` or admin command) to load `automarket_config`. + +### Notes + +Full design spec: `docs/superpowers/specs/2026-05-27-automarket-overhaul-design.md` + +The `raw_material_prices` table is not dropped — only removed from active query paths — to preserve historical reference and allow rollback. +The `@is_pvp` parameter on `sp_RecordResourceGathered` defaults to `0`, so any call site not yet updated silently falls back to PvE treatment rather than failing. +Part D refactoring is scoped to analysis + targeted fixes only; broad restructuring of the market engine is out of scope. + +--- + +## IMPROVEMENT-031 - AdminTool: AutoMarket Management and Statistics + +Status: DONE +Priority: HIGH +Area: Admin Tool / Economy / AutoMarket + +### Description + +Add a dedicated **AutoMarket** panel to the AdminTool with four tabs: Config, Trade List, Statistics, and Orders. Operators currently have no in-tool way to tune AutoMarket parameters, manage the item trade list, or inspect economy health — all changes require direct DB access. + +Follows the Seasons panel pattern: single nav entry, tabbed ViewModel, MVVM + ChangeQueue. No new server-side API is needed except one thin request handler for the manual refresh trigger. + +### Tab 1 — Config + +Editable grid of all `automarket_config` parameters with human-readable labels: +`plasma_anchor_fraction`, `plasma_buy_qty_fraction`, `daily_plasma_budget_nic`, `daily_rawmat_budget_nic`, `product_sell_margin`, `raw_mat_sell_multiplier`, `product_buyback_margin`, `resource_ds_ratio_min`, `resource_ds_ratio_max`. + +Changes are queued via `ChangeQueue` and committed through the existing SQL script / direct-apply pipeline. + +A **Refresh Now** toolbar button sends a server request to immediately trigger `MarketAutoOrdersManager` — requires one new thin request handler wired via the existing `Commands.cs` / Autofac pattern. + +### Tab 2 — Trade List + +Editable grid of `market_orders_configuration` rows. Columns: translated item name, definition name (read-only), amount (editable). Translated names via the existing translations system; falls back to `definitionname`. + +- **Add item** — searchable item picker backed by `entitydefaults`, filterable by translated or internal name. +- **Remove item** — warns if the item is a dependency of others (via `v_required_raw_materials`). +- **Queue Save** per row — follows the ChangeQueue deduplication pattern ([[IMPROVEMENT-016]]). + +A read-only sub-panel below the grid shows the derived raw materials that will be generated from the current trade list (via `v_required_raw_materials`), also with translated names. + +### Tab 3 — Statistics + +Read-only dashboard, refreshes on demand. + +- **NIC Flow** — plasma NIC in and rawmat NIC out for today / last 7 days / total (from `plasma_sold` and `rawmat_purchased`); net delta per period; today's spend vs daily cap shown as a ratio. +- **Pricing Trace** — per raw material: translated name, plasma anchor input, supply/demand ratio, PvP risk multiplier, resulting price. Explains why each material is priced as it is. +- **Gather Breakdown** — per raw material: gather volume over last 7 days split by PvP vs PvE (from `resources_gathered_daily.is_pvp`). Validates risk multiplier inputs. + +### Tab 4 — Orders + +Read-only live snapshot of all active AutoMarket orders. Columns: translated item name, order type (Buy / Sell / Buyback), price, amount, translated market/base name, category (Plasma / Raw Material / Production Item). Filterable by order type and category. + +Market/base names use translated display names via the existing translations system, with fallback to internal name. + +### Impact + +Without this panel, every config change, trade list edit, and economy health check requires direct DB access. The AdminTool gives operators a safe, auditable surface for the most frequently tuned AutoMarket levers introduced in [[IMPROVEMENT-030]] and [[ISSUE-024]]. + +### Proposed Implementation + +**Server side:** +- Add one new `Commands.cs` entry and request handler (`AutoMarketRefreshHandler` or similar) that calls `MarketAutoOrdersManager` refresh method directly. +- Register via Autofac following existing handler patterns. + +**AdminTool:** +- `AutoMarketViewModel` — root VM, owns tab VMs, wires Refresh Now command via server request. +- `AutoMarketConfigViewModel` — loads `automarket_config`; editable rows; ChangeQueue integration. +- `AutoMarketTradeListViewModel` — loads `market_orders_configuration`; item picker dialog; derived raw material sub-panel; ChangeQueue integration. +- `AutoMarketStatisticsViewModel` — loads NIC flow aggregates, pricing trace, gather breakdown; refresh-on-demand. +- `AutoMarketOrdersViewModel` — loads live market order snapshot; filter support; refresh-on-demand. +- Corresponding XAML Views for each VM. +- Wire `AutoMarketViewModel` into `MainViewModel` following the same pattern as `SeasonsViewModel`. + +**No new DB tables required.** All data comes from existing tables and views introduced in IMPROVEMENT-030 and ISSUE-024. + +### Notes + +Translations: use the existing translations system throughout (item names, market/base names). Fall back to internal names if no translation exists — never show raw definition IDs to the operator. +ChangeQueue deduplication for Config and Trade List tabs — see [[IMPROVEMENT-016]]. +The derived raw materials sub-panel in Trade List is read-only and does not generate ChangeQueue entries. +The Refresh Now button should be disabled while a refresh is in progress and should surface any server-side error to the operator. +Pricing Trace data source: query the last computed values from `resource_market_prices` (or equivalent output of `recalculate_raw_material_prices`) — no live re-computation in the AdminTool. + +### Implementation + +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`. diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index ff8ce06..0738be9 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -1,6 +1,98 @@ # Last ID used -021 +024 + +## ISSUE-024 - AutoMarket pricing structurally excludes player crafters from the production economy + +Status: DONE +Priority: CRITICAL +Area: Market / Economy + +### Problem +AutoMarket's raw material buy prices are designed to be the best on the market, which means farmers preferentially sell to AutoMarket rather than to player crafters. Crafters who need raw materials are left with two unviable options: outbid AutoMarket for farmer supply (unsustainable) or buy from AutoMarket's own raw material sell orders at 2× production cost. + +At 2× production cost for inputs, crafters cannot profitably undercut AutoMarket's production sell orders, which are priced at exactly 1× production cost. This makes player crafting economically non-viable. AutoMarket ends up as both the dominant raw material buyer and the dominant production item seller, with no player-to-player trade in either segment. + +### Impact +- Player crafters have no viable economic role when competing against AutoMarket. +- The raw material market is dominated by AutoMarket; farmer → crafter trade does not develop. +- The production market stabilizes at AutoMarket's prices with no player undercutting possible. +- NIC injection via raw material purchases is currently uncapped (only plasma purchases have a daily budget cap), creating an inflation risk as AutoMarket absorbs all farming output. +- Economy health degrades to a two-step loop (farmer → AutoMarket → buyer) with no value-add player layer. + +### Proposed Fix +Three levers, in order of impact: + +1. **Add a margin to production sell prices** — sell production items at production cost × 1.2–1.3 instead of exactly 1×. This creates headroom for crafters who source materials below AutoMarket's buy price to profitably undercut. Lowest implementation cost: one config parameter. + +2. **Reduce raw material sell markup from 2× to ~1.3×** — crafters buying from AutoMarket's sell orders at 1.3× can still craft and sell below AutoMarket's marked-up production prices, creating a viable crafter niche even without direct farmer supply. + +3. **Add production item buyback orders** — AutoMarket posts buy orders for production items at ~0.85× production cost. Gives crafters a guaranteed exit price, making crafting economically viable in thin player markets and creating a NIC sink that scales with production volume. Largest implementation effort but highest long-term impact. + +The minimum viable fix is (1) + (2) as config-only changes. Adding (3) is the complete solution. + +### Notes +- Root cause is that AutoMarket is positioned as a market maker (best price) rather than a backstop (last resort). The gap between AutoMarket prices and fair value should be where player trade operates. +- AutoMarket does not currently buy production items back from players. +- The 24h price refresh lag creates an arbitrage window but does not address the structural problem. +- Cap raw material purchase budget similarly to the plasma budget (`daily_plasma_budget_nic`) to prevent unbounded NIC injection. + +--- + +## ISSUE-023 - Editing existing Season objectives does not save 'Is Daily' flag changes + +Status: DONE +Priority: CRITICAL +Area: Seasons / Admin Tool + +### Problem +When an admin edits an existing objective on an existing Season and changes the 'Is Daily' flag, the change is not persisted. The flag reverts to its previous value after saving, leaving the objective in an incorrect state with no feedback to the admin. + +### Impact +Admins cannot correct the daily/non-daily designation of objectives on live seasons. This blocks fixing misconfigured objectives without deleting and recreating them, which is disruptive and may affect active participant progress. + +### Proposed Fix +- Locate the save path for objective edits in the Season Admin Tool (likely `SeasonDetailViewModel` or equivalent objective edit command). +- Verify that `IsDaily` is included in the change set sent to the server when building the objective update payload. +- Confirm the server-side handler and repository update include the `is_daily` column in the `UPDATE` statement. +- Fix whichever layer is dropping the field (UI binding, change-set builder, or SQL update). + +### Notes +- Reproduces on existing seasons with existing objectives; new objectives are unconfirmed. +- Check whether other boolean flags on objectives (e.g. `IsActive`, visibility flags) are similarly dropped — the root cause may affect a wider set of fields. + +--- + +## ISSUE-022 - Season activity points awarded on market orders that are immediately cancelled (exploit) + +Status: DONE +Priority: CRITICAL +Area: Seasons / Activities / Market + +### Problem +A player can place a buy order on the market and immediately cancel it, yet still receive season activity points for the order placement. The same exploit likely applies to sell orders and potentially other NIC-related market actions. This allows instant, repeatable season progression with no actual economic commitment. + +### Impact +Players can exploit this to gain unlimited season points with zero cost (place order, cancel, repeat). This undermines season integrity, devalues legitimate progression, and constitutes a confirmed exploit that must be addressed before widespread abuse occurs. + +### Proposed Fix +Two candidate approaches, in order of preference: + +1. **Award points only on order fulfillment** — move the activity hook from order placement to order execution (when the trade actually settles). This is the correct semantic fix: a fulfilled trade represents real economic activity. +2. **Award points only on non-cancelled orders** — on cancellation, reverse or forfeit any points that were awarded at placement time. More complex; requires tracking awarded points per order. + +The fastest mitigation is to not credit activity at order placement at all, only at fulfillment. Investigate whether sell orders and other NIC actions share the same vulnerability (likely yes — audit all market-related activity hooks). + +### Notes +- Confirmed for buy orders; sell orders and other NIC actions are suspected but unconfirmed. +- Cross-reference `ISSUE-020` (NIC spend activity for market purchases) — the fix for that issue and this one likely share the same hook call site. +- Audit all activity hooks triggered by market events to scope the full surface area. +- Fixed by removing `buyOrderDeposit` (NicSpent) and `buyOrderPayBack` (NicEarned) from +`CharacterWallet.OnCommited`. `TransportAssignmentSubmit` double-count also fixed in the +same change. NicSpent for actual market fulfillments is unaffected (handled by explicit +hooks in `Market.cs`). + +--- ## ISSUE-021 - NPC fleeing state speed reduction insufficient or not applied diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index 3b93254..edffe11 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -30,6 +30,7 @@ Generated from DBML structure. - [artifactspawninfo](#artifactspawninfo) - [artifacttypes](#artifacttypes) - [attributeFlags](#attributeflags) +- [automarket_config](#automarket-config) - [automarket_unbought_resources](#automarket-unbought-resources) - [automarket_unsold_leftovers](#automarket-unsold-leftovers) - [beams](#beams) @@ -990,6 +991,33 @@ Generated from DBML structure. --- +## automarket_config + +**Schema:** `dbo` + +### Columns + +| Column | Definition | +|---|---| +| `param_name` | `varchar(100) [not null, pk]` | +| `param_value` | `float [not null]` | + +### Seeded rows + +| param_name | param_value | Description | +|---|---|---| +| `plasma_anchor_fraction` | `0.15` | | +| `plasma_buy_qty_fraction` | `0.60` | | +| `daily_plasma_budget_nic` | `500000` | | +| `resource_ds_ratio_min` | `0.25` | | +| `resource_ds_ratio_max` | `4.0` | | +| `product_sell_margin` | `1.2` | Product sell orders priced at production_cost × this value (was 1.0). Creates headroom for player crafters to undercut AutoMarket. | +| `raw_mat_sell_multiplier` | `1.5` | Raw material sell orders priced at production_cost × this value (was 2.0). Reduces input cost barrier for crafters. | +| `product_buyback_margin` | `0.80` | AutoMarket buys production items back at production_cost × this value. Guarantees crafters an exit price floor. | +| `daily_rawmat_budget_nic` | `5000000` | Max NIC paid for raw material buy order fulfillments per UTC calendar day. Caps NIC injection from AutoMarket raw material purchases. | + +--- + ## automarket_unbought_resources **Schema:** `dbo` @@ -2339,6 +2367,26 @@ Generated from DBML structure. --- +## discord_pin_state + +**Schema:** `dbo` + +Stores the Discord channel ID and message ID of the currently pinned message per pin slot (DailyPool=0, Leaderboard=1). Used by the server to unpin the previous announcement when a new one is sent. + +### Columns + +| Column | Definition | +|---|---| +| `pin_slot` | `tinyint [not null]` | +| `discord_channel_id` | `varchar(20) [not null]` | +| `discord_message_id` | `varchar(20) [not null]` | + +### Indexes + +- `pin_slot [pk]` + +--- + ## defaultfieldscalculation **Schema:** `dbo` @@ -5310,6 +5358,27 @@ Generated from DBML structure. --- +## rawmat_purchased + +**Schema:** `dbo` + +Tracks NIC paid for raw material AutoMarket buy order fulfillments. Used by `usp_RefreshAutoMarketOrders` to enforce the daily raw material purchase budget cap (`daily_rawmat_budget_nic`). + +### Columns + +| Column | Definition | +|---|---| +| `purchased_on` | `date [not null]` | +| `item_definition` | `int [not null]` | +| `quantity` | `bigint [not null]` | +| `income` | `float [not null]` | + +**Primary Key:** (purchased_on, item_definition) + +Rows older than 90 days are pruned by `recalculate_raw_material_prices`. + +--- + ## polls **Schema:** `dbo` @@ -5571,6 +5640,8 @@ Generated from DBML structure. ## raw_material_prices +> **Deprecated (IMPROVEMENT-030):** This table is no longer read by any active query path. Rows are retained as historical reference. Do not add new query dependencies on this table. + **Schema:** `dbo` ### Columns @@ -5730,6 +5801,7 @@ Generated from DBML structure. | `gathered_on` | `date [not null]` | | `resource_name` | `varchar(100) [not null]` | | `quantity` | `bigint [not null]` | +| `is_pvp` | `bit [not null, default: 0]` | --- @@ -5744,6 +5816,7 @@ Generated from DBML structure. | `gathered_on` | `date [not null]` | | `resource_name` | `varchar(100) [not null]` | | `quantity` | `bigint [not null]` | +| `is_pvp` | `bit [not null, default: 0]` | --- diff --git a/docs/db_structure/migrations/20260528_improvement_030_automarket.sql b/docs/db_structure/migrations/20260528_improvement_030_automarket.sql new file mode 100644 index 0000000..a2585c6 --- /dev/null +++ b/docs/db_structure/migrations/20260528_improvement_030_automarket.sql @@ -0,0 +1,58 @@ +-- IMPROVEMENT-030: AutoMarket Overhaul — NIC Injection Control, Dynamic Pricing, Performance Refactor +-- Adds is_pvp to resources_gathered and resources_gathered_daily (Part B). +-- Creates automarket_config key-value table with default parameters (Part A). +-- +-- Apply stored procedure and view changes separately by running the updated +-- .sql files in docs/db_structure/stored_procedures/ and docs/db_structure/views/ +-- in this order: sp_RecordResourceGathered, consolidate_statistics, +-- recalculate_raw_material_prices, usp_RefreshAutoMarketOrders, v_all_production_costs. + +BEGIN TRANSACTION; + +-- 1. Add is_pvp to resources_gathered +IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'resources_gathered' AND COLUMN_NAME = 'is_pvp' +) +BEGIN + ALTER TABLE dbo.resources_gathered + ADD is_pvp BIT NOT NULL DEFAULT 0; +END + +-- 2. Add is_pvp to resources_gathered_daily +IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'resources_gathered_daily' AND COLUMN_NAME = 'is_pvp' +) +BEGIN + ALTER TABLE dbo.resources_gathered_daily + ADD is_pvp BIT NOT NULL DEFAULT 0; +END + +-- 3. Create automarket_config +IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = 'automarket_config' +) +BEGIN + CREATE TABLE dbo.automarket_config ( + param_name VARCHAR(100) NOT NULL, + param_value FLOAT NOT NULL, + CONSTRAINT PK_automarket_config PRIMARY KEY (param_name) + ); +END + +-- 4. Seed default parameters (idempotent) +MERGE INTO dbo.automarket_config AS target +USING (VALUES + ('plasma_anchor_fraction', 0.15), + ('plasma_buy_qty_fraction', 0.60), + ('daily_plasma_budget_nic', 500000), + ('resource_ds_ratio_min', 0.25), + ('resource_ds_ratio_max', 4.0) +) AS src (param_name, param_value) +ON target.param_name = src.param_name +WHEN NOT MATCHED THEN + INSERT (param_name, param_value) VALUES (src.param_name, src.param_value); + +COMMIT; diff --git a/docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql b/docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql new file mode 100644 index 0000000..ba39685 --- /dev/null +++ b/docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql @@ -0,0 +1,26 @@ +BEGIN TRANSACTION; + +-- New table: tracks NIC paid for raw material AutoMarket buy order fulfillments +IF OBJECT_ID('dbo.rawmat_purchased', 'U') IS NULL +BEGIN + CREATE TABLE dbo.rawmat_purchased ( + purchased_on DATE NOT NULL, + item_definition INT NOT NULL, + quantity BIGINT NOT NULL, + income FLOAT NOT NULL, + CONSTRAINT PK_rawmat_purchased PRIMARY KEY (purchased_on, item_definition) + ); +END; + +-- New automarket_config rows for ISSUE-024 crafter viability pricing +MERGE INTO dbo.automarket_config AS target +USING (VALUES + ('product_sell_margin', 1.2), + ('raw_mat_sell_multiplier', 1.5), + ('product_buyback_margin', 0.80), + ('daily_rawmat_budget_nic', 5000000.0) +) AS src (param_name, param_value) +ON target.param_name = src.param_name +WHEN NOT MATCHED THEN INSERT (param_name, param_value) VALUES (src.param_name, src.param_value); + +COMMIT; diff --git a/docs/db_structure/migrations/add_discord_pin_state.sql b/docs/db_structure/migrations/add_discord_pin_state.sql new file mode 100644 index 0000000..e93a575 --- /dev/null +++ b/docs/db_structure/migrations/add_discord_pin_state.sql @@ -0,0 +1,12 @@ +IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = 'discord_pin_state' +) +BEGIN + CREATE TABLE discord_pin_state ( + pin_slot TINYINT NOT NULL, + discord_channel_id VARCHAR(20) NOT NULL, + discord_message_id VARCHAR(20) NOT NULL, + CONSTRAINT PK_discord_pin_state PRIMARY KEY (pin_slot) + ); +END diff --git a/docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql index b251883..249f0ad 100644 Binary files a/docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql and b/docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql differ diff --git a/docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql index 6eab078..cdcde77 100644 Binary files a/docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql and b/docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql differ diff --git a/docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql new file mode 100644 index 0000000..9fe95bd --- /dev/null +++ b/docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql @@ -0,0 +1,32 @@ +USE [perpetuumsa] +GO +/****** Object: StoredProcedure [dbo].[sp_RecordRawMatPurchased] Script Date: 28.05.2026 ******/ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +---- Upsert raw material AutoMarket purchase record for daily NIC budget tracking + +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatPurchased] + @purchased_on DATE, + @item_def INT, + @quantity BIGINT, + @income FLOAT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.rawmat_purchased AS target + USING (SELECT @purchased_on, @item_def, @quantity, @income) + AS source(purchased_on, item_definition, quantity, income) + ON target.purchased_on = source.purchased_on + AND target.item_definition = source.item_definition + WHEN MATCHED THEN + UPDATE SET + quantity = target.quantity + source.quantity, + income = target.income + source.income + WHEN NOT MATCHED THEN + INSERT (purchased_on, item_definition, quantity, income) + VALUES (source.purchased_on, source.item_definition, source.quantity, source.income); +END; +GO diff --git a/docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql index 3fd76a8..0caa1bb 100644 Binary files a/docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql and b/docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql differ 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 e0a50a2..1ae53d5 100644 Binary files a/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql and b/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql differ diff --git a/docs/db_structure/views/v_all_production_costs.sql b/docs/db_structure/views/v_all_production_costs.sql index 1ebc1f6..8bb3b48 100644 --- a/docs/db_structure/views/v_all_production_costs.sql +++ b/docs/db_structure/views/v_all_production_costs.sql @@ -1,4 +1,4 @@ -/****** Object: View [dbo].[v_all_production_costs] Script Date: 10.05.2026 7:27:10 ******/ +/****** Object: View [dbo].[v_all_production_costs] Script Date: 28.05.2026 ******/ SET ANSI_NULLS ON GO @@ -6,7 +6,7 @@ SET QUOTED_IDENTIFIER ON GO ----- Use both based and calculated values +---- Use dynamic resource_market_prices; fallback is max-scarcity formula (no raw_material_prices dependency) CREATE VIEW [dbo].[v_all_production_costs] AS WITH all_items AS ( @@ -15,7 +15,7 @@ WITH all_items AS ( SELECT components AS item FROM production_data ), recursive_materials AS ( - SELECT + SELECT base.item, pd.components AS raw_material, CAST(pd.amount * 2.1 AS FLOAT) AS quantity @@ -44,39 +44,46 @@ latest_market_prices AS ( FROM resource_market_prices rmp WHERE rmp.calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) ), +-- Inline max-scarcity fallback: plasma_anchor x ds_ratio_max x 2.0 +-- Used when a material is completely absent 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, base.price_nic) + ac.total_quantity * ISNULL(mp.unit_price, msp.price) ) AS production_cost_nic FROM aggregated_costs ac - LEFT JOIN latest_market_prices mp + LEFT JOIN latest_market_prices mp ON ac.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT - LEFT JOIN raw_material_prices base - ON ac.raw_material COLLATE DATABASE_DEFAULT = base.material_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp GROUP BY ac.product ), raw_resources AS ( - SELECT - rmp.material_name AS product, - ISNULL(mp.unit_price, rmp.price_nic) AS production_cost_nic - FROM raw_material_prices rmp - LEFT JOIN latest_market_prices mp - ON rmp.material_name COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT - WHERE NOT EXISTS ( - SELECT 1 FROM production_data pd WHERE pd.product = rmp.material_name - ) + 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 +SELECT product, ROUND(production_cost_nic, 2) AS production_cost_nic FROM final_costs; -GO \ No newline at end of file +GO diff --git a/docs/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md b/docs/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md new file mode 100644 index 0000000..4fd3310 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md @@ -0,0 +1,522 @@ +# IMPROVEMENT-029: Discord Pinned Announcements 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:** When the server sends daily pool or leaderboard announcements to Discord, automatically pin the message and unpin the previous one for that slot so players can always find them. + +**Architecture:** A new `DiscordPinnableMessage` event type carries a `PinSlot` tag through the existing event pipeline. `EventListenerService` handles it by sending, unpinning the old message (looked up from a `discord_pin_state` DB table), pinning the new one, and persisting the new message ID. `ChannelManager` gets a `PinnedAnnouncement` method that emits this new type. `SeasonService` calls it for daily pool and leaderboard announcements only. + +**Tech Stack:** C# 12 / .NET 8, Discord.Net (`IUserMessage.PinAsync/UnpinAsync`, `IMessageChannel.GetMessageAsync`), SQL Server (`MERGE` upsert), Autofac DI. + +--- + +## File Map + +| Action | Path | +|---|---| +| Create | `src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs` | +| Create | `src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs` | +| Create | `src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs` | +| Create | `src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs` | +| Modify | `src/Perpetuum/Services/Channels/IChannelManager.cs` | +| Modify | `src/Perpetuum/Services/Channels/ChannelManager.cs` | +| Modify | `src/Perpetuum/Services/EventServices/EventListenerService.cs` | +| Modify | `src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs` | +| Modify | `src/Perpetuum/Services/Seasons/SeasonService.cs` | +| Create | `docs/db_structure/migrations/add_discord_pin_state.sql` | + +--- + +## Task 1: DB migration — create `discord_pin_state` table + +**Files:** +- Create: `docs/db_structure/migrations/add_discord_pin_state.sql` + +- [ ] **Step 1: Create the migration SQL file** + +Create `docs/db_structure/migrations/add_discord_pin_state.sql` with: + +```sql +IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = 'discord_pin_state' +) +BEGIN + CREATE TABLE discord_pin_state ( + pin_slot TINYINT NOT NULL, + discord_channel_id VARCHAR(20) NOT NULL, + discord_message_id VARCHAR(20) NOT NULL, + CONSTRAINT PK_discord_pin_state PRIMARY KEY (pin_slot) + ); +END +``` + +- [ ] **Step 2: Run the migration against your development database** + +Open SSMS (or your preferred SQL client), connect to the game database, and execute the script. Verify that `discord_pin_state` now appears in the table list with zero rows. + +- [ ] **Step 3: Commit** + +```bash +git add docs/db_structure/migrations/add_discord_pin_state.sql +git commit -m "feat: add discord_pin_state table migration (IMPROVEMENT-029)" +``` + +--- + +## Task 2: `PinSlot` enum and `DiscordPinnableMessage` + +**Files:** +- Create: `src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs` +- Create: `src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs` + +- [ ] **Step 1: Create `PinSlot.cs`** + +```csharp +namespace Perpetuum.Services.EventServices.EventMessages +{ + public enum PinSlot + { + DailyPool = 0, + Leaderboard = 1, + } +} +``` + +- [ ] **Step 2: Create `DiscordPinnableMessage.cs`** + +```csharp +namespace Perpetuum.Services.EventServices.EventMessages +{ + public class DiscordPinnableMessage : IEventMessage + { + public EventType Type => EventType.PerpetuumToDiscord; + public ulong DiscordChannelId { get; } + public string Nick { get; } + public string Message { get; } + public PinSlot PinSlot { get; } + + public DiscordPinnableMessage(ulong discordChannelId, string nick, string message, PinSlot pinSlot) + { + DiscordChannelId = discordChannelId; + Nick = nick; + Message = message; + PinSlot = pinSlot; + } + } +} +``` + +- [ ] **Step 3: Build to verify no errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with zero errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs +git add src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs +git commit -m "feat: add PinSlot enum and DiscordPinnableMessage (IMPROVEMENT-029)" +``` + +--- + +## Task 3: `IDiscordPinStateRepository` and `DiscordPinStateRepository` + +**Files:** +- Create: `src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs` +- Create: `src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs` + +- [ ] **Step 1: Create `IDiscordPinStateRepository.cs`** + +```csharp +using Perpetuum.Services.EventServices.EventMessages; + +namespace Perpetuum.Services.EventServices +{ + public interface IDiscordPinStateRepository + { + (ulong channelId, ulong messageId)? Get(PinSlot slot); + void Upsert(PinSlot slot, ulong channelId, ulong messageId); + } +} +``` + +- [ ] **Step 2: Create `DiscordPinStateRepository.cs`** + +```csharp +using Perpetuum.Data; +using Perpetuum.Services.EventServices.EventMessages; + +namespace Perpetuum.Services.EventServices +{ + public class DiscordPinStateRepository : IDiscordPinStateRepository + { + public (ulong channelId, ulong messageId)? Get(PinSlot slot) + { + var record = Db.Query( + "SELECT discord_channel_id, discord_message_id " + + "FROM discord_pin_state WHERE pin_slot = @pin_slot") + .SetParameter("@pin_slot", (int)slot) + .ExecuteSingleRow(); + + if (record == null) + return null; + + return ( + ulong.Parse(record.GetValue("discord_channel_id")), + ulong.Parse(record.GetValue("discord_message_id")) + ); + } + + public void Upsert(PinSlot slot, ulong channelId, ulong messageId) + { + Db.Query( + "MERGE discord_pin_state AS t " + + "USING (VALUES (@pin_slot, @channel_id, @message_id)) " + + " AS s (pin_slot, discord_channel_id, discord_message_id) " + + "ON t.pin_slot = s.pin_slot " + + "WHEN MATCHED THEN " + + " UPDATE SET discord_channel_id = s.discord_channel_id, " + + " discord_message_id = s.discord_message_id " + + "WHEN NOT MATCHED THEN " + + " INSERT (pin_slot, discord_channel_id, discord_message_id) " + + " VALUES (s.pin_slot, s.discord_channel_id, s.discord_message_id);") + .SetParameter("@pin_slot", (int)slot) + .SetParameter("@channel_id", channelId.ToString()) + .SetParameter("@message_id", messageId.ToString()) + .ExecuteNonQuery(); + } + } +} +``` + +- [ ] **Step 3: Build to verify no errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with zero errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs +git add src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs +git commit -m "feat: add DiscordPinStateRepository (IMPROVEMENT-029)" +``` + +--- + +## Task 4: `IChannelManager.PinnedAnnouncement` and `ChannelManager.PinnedAnnouncement` + +**Files:** +- Modify: `src/Perpetuum/Services/Channels/IChannelManager.cs` +- Modify: `src/Perpetuum/Services/Channels/ChannelManager.cs` + +- [ ] **Step 1: Check the code graph for impact** + +Before modifying the interface, confirm all implementors and high-traffic consumers: + +```powershell +.\tools\query-graph.ps1 IChannelManager -Direction in +.\tools\query-graph.ps1 ChannelManager -Direction in +``` + +Check `docs/graph/GRAPH_REPORT.md` to see if either is a God Node. If `graph.json` is absent, skip and continue. Expected: one implementor (`ChannelManager`). If unexpected implementors appear, they must also receive the new method before proceeding. + +- [ ] **Step 2: Add the method to `IChannelManager.cs`** + +In `src/Perpetuum/Services/Channels/IChannelManager.cs`, add a `using` for the event messages namespace at the top: + +```csharp +using Perpetuum.Services.EventServices.EventMessages; +``` + +Then add the method declaration after the existing `Announcement` signature: + +```csharp +void PinnedAnnouncement(string channelName, Character sender, string message, PinSlot pinSlot); +``` + +The `Announcement` declaration is on line 32. The full relevant section after your edit: + +```csharp +void Announcement(string channelName, Character sender, string message, Character? recipient = null); +void PinnedAnnouncement(string channelName, Character sender, string message, PinSlot pinSlot); +void KickOrBan(string channelName, Character issuer, Character character, string message, bool ban); +``` + +- [ ] **Step 3: Implement the method in `ChannelManager.cs`** + +In `src/Perpetuum/Services/Channels/ChannelManager.cs`, insert the new method immediately after the closing brace of `Announcement()` (around line 359). The existing `Announcement()` ends with the recipient path; add the new method after it: + +```csharp +public void PinnedAnnouncement(string channelName, Character sender, string message, PinSlot pinSlot) +{ + if (!_channels.TryGetValue(channelName, out Channel? channel)) + return; + + channel.Logger.LogMessage(sender, message); + channel.SendMessageToAll(_sessionManager, sender, message); + + if (channel.DiscordId != null && sender.Nick != "Discord") + { + _eventChannel.PublishMessage( + new DiscordPinnableMessage( + channel.DiscordId.Value, + sender.Nick, + message, + pinSlot)); + } +} +``` + +- [ ] **Step 4: Build to verify no errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/Perpetuum/Services/Channels/IChannelManager.cs +git add src/Perpetuum/Services/Channels/ChannelManager.cs +git commit -m "feat: add PinnedAnnouncement to ChannelManager (IMPROVEMENT-029)" +``` + +--- + +## Task 5: `EventListenerService` — handle `DiscordPinnableMessage` + +**Files:** +- Modify: `src/Perpetuum/Services/EventServices/EventListenerService.cs` + +- [ ] **Step 1: Check the code graph for impact** + +```powershell +.\tools\query-graph.ps1 EventListenerService -Direction in +``` + +Check `docs/graph/GRAPH_REPORT.md` to see if it is a God Node. If `graph.json` is absent, skip and continue. This confirms the scope of callers affected by the constructor change before touching the file. + +- [ ] **Step 2: Add the repository field and update the constructor** + +In `EventListenerService.cs`, add the field after `_globalConfiguration`: + +```csharp +private readonly IDiscordPinStateRepository _pinStateRepository; +``` + +Update the constructor signature and body (the constructor currently starts at line 24): + +```csharp +public EventListenerService(GlobalConfiguration globalConfiguration, IDiscordPinStateRepository pinStateRepository) +{ + _observers = new Dictionary>(); + _queue = new ConcurrentQueue(); + + _client = new DiscordSocketClient(new DiscordSocketConfig + { + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent + }); + + _globalConfiguration = globalConfiguration; + _pinStateRepository = pinStateRepository; +} +``` + +- [ ] **Step 3: Add the `DiscordPinnableMessage` branch in `PublishMessage`** + +In `PublishMessage`, the existing `if` block ends at line 53 with `}`. Add the new branch immediately after it, before the `else` that enqueues. Note: the final implementation should wrap the entire `Task.Run` body in an outer `try/catch { }` so a `SendMessageAsync` failure doesn't leave the task unobserved: + +```csharp +public void PublishMessage(IEventMessage message) +{ + if (message is DiscordIntegrationMessage discordMessage && + discordMessage.Type == EventType.PerpetuumToDiscord) + { + if (_client.GetChannel(discordMessage.ChannelDiscordId) is IMessageChannel discordChannel) + { + string messageToSend = $"**<{discordMessage.Nick}>**: {discordMessage.Message}"; + discordChannel.SendMessageAsync( + messageToSend, + allowedMentions: new AllowedMentions { AllowedTypes = AllowedMentionTypes.Users }); + } + } + else if (message is DiscordPinnableMessage pinnableMessage) + { + if (_client.GetChannel(pinnableMessage.DiscordChannelId) is IMessageChannel discordChannel) + { + Task.Run(async () => + { + string messageToSend = $"**<{pinnableMessage.Nick}>**: {pinnableMessage.Message}"; + var sent = await discordChannel.SendMessageAsync( + messageToSend, + allowedMentions: new AllowedMentions { AllowedTypes = AllowedMentionTypes.Users }); + + var existing = _pinStateRepository.Get(pinnableMessage.PinSlot); + if (existing.HasValue) + { + try + { + var oldMsg = await discordChannel.GetMessageAsync(existing.Value.messageId); + if (oldMsg is IUserMessage oldUserMsg) + await oldUserMsg.UnpinAsync(); + } + catch { } + } + + try { await sent.PinAsync(); } + catch { } + + _pinStateRepository.Upsert( + pinnableMessage.PinSlot, + pinnableMessage.DiscordChannelId, + sent.Id); + }); + } + } + else + { + _queue.Enqueue(message); + } +} +``` + +- [ ] **Step 4: Build to verify no errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/Perpetuum/Services/EventServices/EventListenerService.cs +git commit -m "feat: handle DiscordPinnableMessage with pin/unpin logic in EventListenerService (IMPROVEMENT-029)" +``` + +--- + +## Task 6: Autofac registration + +**Files:** +- Modify: `src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs` + +- [ ] **Step 1: Register `DiscordPinStateRepository`** + +In `PerpetuumBootstrapper.cs`, find the comment `// OPP: EventListenerService and consumers` (around line 581). Add the registration on the line immediately before it: + +```csharp +_ = _builder.RegisterType().As().SingleInstance(); + +// OPP: EventListenerService and consumers +``` + +You will also need to add a `using` for the EventServices namespace at the top of the bootstrapper file if it is not already present: + +```csharp +using Perpetuum.Services.EventServices; +``` + +- [ ] **Step 2: Build to verify no errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with zero errors. Autofac will now inject `DiscordPinStateRepository` into `EventListenerService` automatically because `EventListenerService` is registered as `SingleInstance()` and Autofac resolves constructor parameters by type. + +- [ ] **Step 3: Commit** + +```bash +git add src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs +git commit -m "feat: register DiscordPinStateRepository in Autofac (IMPROVEMENT-029)" +``` + +--- + +## Task 7: `SeasonService` — switch two call sites to `PinnedAnnouncement` + +**Files:** +- Modify: `src/Perpetuum/Services/Seasons/SeasonService.cs` + +- [ ] **Step 1: Add `using` for `PinSlot`** + +At the top of `SeasonService.cs`, add: + +```csharp +using Perpetuum.Services.EventServices.EventMessages; +``` + +- [ ] **Step 2: Update `AnnounceDailyPool`** + +Locate the `AnnounceDailyPool` method (around line 422). Change the final `Announcement` call: + +```csharp +// Before: +_channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, sb.ToString()); + +// After: +_channelManager.Value.PinnedAnnouncement(SeasonChannelName, _announcer.Value, sb.ToString(), PinSlot.DailyPool); +``` + +- [ ] **Step 3: Update `AnnounceLeaderboard`** + +Locate the `AnnounceLeaderboard` method (around line 377). Change the final `Announcement` call: + +```csharp +// Before: +_channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); + +// After: +_channelManager.Value.PinnedAnnouncement(SeasonChannelName, _announcer.Value, chatMessage.ToString(), PinSlot.Leaderboard); +``` + +- [ ] **Step 4: Build to verify no errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/Perpetuum/Services/Seasons/SeasonService.cs +git commit -m "feat: pin daily pool and leaderboard announcements in Discord (IMPROVEMENT-029)" +``` + +--- + +## Manual Validation + +After all tasks are complete, validate end-to-end in a running server with a Discord bot that has `Manage Messages` permission on the target channel: + +1. **First daily pool announcement** — trigger via server restart or admin command. Verify the message appears in Discord and is pinned (check Pinned Messages in Discord). +2. **Second daily pool announcement** — trigger again. Verify the first pin is removed and the new message is pinned. +3. **Leaderboard announcement** — wait for or trigger a leaderboard update. Verify it pins independently — the daily pool pin should still be present alongside it. +4. **Unpin failure recovery** — manually delete the pinned Discord message, then trigger another announcement. Verify the server does not crash and the new message is pinned successfully (the `catch { }` in the unpin path handles this). +5. **Missing permission** — temporarily remove `Manage Messages` from the bot, trigger an announcement. Verify the message still sends to Discord, the server does not crash, and the message ID is still persisted to `discord_pin_state`. +6. **Restart recovery** — confirm `discord_pin_state` has rows. Restart the server. Trigger another announcement. Verify the old message is unpinned and the new one is pinned (DB-persisted ID was used for the unpin). + +--- + +## Potential Regressions + +- All `Announcement()` call sites in `SeasonService` other than the two modified here are unchanged. +- The `DiscordIntegrationMessage` branch in `EventListenerService.PublishMessage` is unchanged. +- In-game chat delivery in `ChannelManager` is unchanged for both `Announcement` and `PinnedAnnouncement`. +- No changes to zone update paths, NPC AI, combat, or market systems. diff --git a/docs/superpowers/plans/2026-05-25-issue-022-market-order-season-exploit.md b/docs/superpowers/plans/2026-05-25-issue-022-market-order-season-exploit.md new file mode 100644 index 0000000..7cc86b3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-issue-022-market-order-season-exploit.md @@ -0,0 +1,181 @@ +# ISSUE-022: Market Order Season Points Exploit Fix — 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:** Remove three wallet transaction types from season activity recording so that placing/cancelling market buy orders and submitting transport assignments no longer awards free or doubled season points. + +**Architecture:** `CharacterWallet.OnCommited` maps `TransactionType` values to season activity via a switch statement. Three entries are incorrect — two enable the buy-order exploit (`buyOrderDeposit` → NicSpent, `buyOrderPayBack` → NicEarned), one double-counts transport assignment submission (`TransportAssignmentSubmit` → NicSpent, already recorded by an explicit hook in `TransportAssignment.CashInOnSubmit`). The fix is three `case` line deletions in one file. + +**Tech Stack:** C# 12, .NET 8. No test suite — validation is manual. + +--- + +### Task 1: Remove the three exploit/double-count case entries from `CharacterWallet` + +**Files:** +- Modify: `src/Perpetuum/Accounting/Characters/CharacterWallet.cs:53–91` + +**Context:** `OnCommited` is called after every wallet balance change. The `NicSpent` case block currently includes `buyOrderDeposit` (deposit held in escrow — not a real spend) and `TransportAssignmentSubmit` (duplicate of explicit hook in `TransportAssignment.CashInOnSubmit`). The `NicEarned` case block includes `buyOrderPayBack` (deposit refund — not real income). All three must be removed. + +- [ ] **Step 1: Open the file and confirm the current switch block** + +Open `src/Perpetuum/Accounting/Characters/CharacterWallet.cs`. + +Locate `OnCommited` (around line 35). The switch block starting at line 53 should look like this: + +```csharp +switch (transactionType) +{ + case TransactionType.hangarRent: + case TransactionType.hangarRentAuto: + case TransactionType.marketFee: + case TransactionType.buyOrderDeposit: + case TransactionType.corporationCreate: + case TransactionType.extensionLearn: + case TransactionType.ItemRepair: + case TransactionType.ProductionManufacture: + case TransactionType.ProductionResearch: + case TransactionType.ProductionMultiItemRepair: + case TransactionType.ProductionPrototype: + case TransactionType.ProductionMassProduction: + case TransactionType.InsuranceFee: + case TransactionType.BoxRequest: + case TransactionType.MarketTax: + case TransactionType.ModifyMarketOrder: + case TransactionType.SparkUnlock: + case TransactionType.SparkActivation: + case TransactionType.ResearchKitMerge: + case TransactionType.ProductionCPRGForge: + case TransactionType.ItemShopBuy: + case TransactionType.TransportAssignmentSubmit: + SeasonServiceLocator.Instance?.RecordActivity(character.Id, SeasonActivityType.NicSpent, new ActivityEvent((long)Math.Abs(change))); + + break; + case TransactionType.buyOrderPayBack: + case TransactionType.missionPayOut: + case TransactionType.refund: + case TransactionType.InsurancePayOut: + case TransactionType.GoodiePackCredit: + SeasonServiceLocator.Instance?.RecordActivity(character.Id, SeasonActivityType.NicEarned, new ActivityEvent((long)change)); + + break; + default: + break; +} +``` + +Confirm these three lines are present before editing: +- `case TransactionType.buyOrderDeposit:` +- `case TransactionType.TransportAssignmentSubmit:` +- `case TransactionType.buyOrderPayBack:` + +- [ ] **Step 2: Remove the three case lines** + +The switch block after the change must look exactly like this (three lines removed, everything else identical): + +```csharp +switch (transactionType) +{ + case TransactionType.hangarRent: + case TransactionType.hangarRentAuto: + case TransactionType.marketFee: + case TransactionType.corporationCreate: + case TransactionType.extensionLearn: + case TransactionType.ItemRepair: + case TransactionType.ProductionManufacture: + case TransactionType.ProductionResearch: + case TransactionType.ProductionMultiItemRepair: + case TransactionType.ProductionPrototype: + case TransactionType.ProductionMassProduction: + case TransactionType.InsuranceFee: + case TransactionType.BoxRequest: + case TransactionType.MarketTax: + case TransactionType.ModifyMarketOrder: + case TransactionType.SparkUnlock: + case TransactionType.SparkActivation: + case TransactionType.ResearchKitMerge: + case TransactionType.ProductionCPRGForge: + case TransactionType.ItemShopBuy: + SeasonServiceLocator.Instance?.RecordActivity(character.Id, SeasonActivityType.NicSpent, new ActivityEvent((long)Math.Abs(change))); + + break; + case TransactionType.missionPayOut: + case TransactionType.refund: + case TransactionType.InsurancePayOut: + case TransactionType.GoodiePackCredit: + SeasonServiceLocator.Instance?.RecordActivity(character.Id, SeasonActivityType.NicEarned, new ActivityEvent((long)change)); + + break; + default: + break; +} +``` + +- [ ] **Step 3: Build and confirm no compile errors** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: build succeeds with 0 errors. (Warnings are acceptable.) + +- [ ] **Step 4: Manual validation — buy order exploit closed** + +Start the server. Log in with a test character on an active season that has a `NicSpent` objective. + +1. Note the character's current season NicSpent points. +2. Place a buy order for any item at a price that finds no matching sell order. Note: the deposit is taken from the wallet. +3. Wait 10 minutes for `MARKET_CANCEL_TIME` to elapse. +4. Cancel the order. Verify the deposit is returned to the wallet. +5. Confirm: NicSpent points did NOT increase from the deposit, and NicEarned points did NOT increase from the payback. + +- [ ] **Step 5: Manual validation — immediate-fill buy order still awards points** + +1. Ensure a sell order exists on the market for item X at price Y. +2. Place a buy order for item X at price ≥ Y. The order fulfills instantly. +3. Confirm: NicSpent IS awarded for the actual purchase amount (via the explicit hook in `Market.FulfillBuyOrderInstantly`). + +- [ ] **Step 6: Manual validation — transport assignment NicSpent not doubled** + +1. Submit a transport assignment as owner (requires a package and a target base). +2. Check season activity: NicSpent should increase by the reward amount **once**, not twice. + +(If direct point inspection isn't available via UI, check server logs or run a DB query: `SELECT * FROM seasonactivity WHERE characterid = ORDER BY createdon DESC`.) + +- [ ] **Step 7: Commit** + +``` +git add src/Perpetuum/Accounting/Characters/CharacterWallet.cs +git commit -m "fix: remove buyOrderDeposit, buyOrderPayBack, TransportAssignmentSubmit from season wallet hooks (ISSUE-022)" +``` + +--- + +### Task 2: Update backlog + +**Files:** +- Modify: `docs/backlog/issues.md` + +- [ ] **Step 1: Mark ISSUE-022 as DONE** + +In `docs/backlog/issues.md`, find the ISSUE-022 entry and update its status: + +```markdown +Status: DONE +``` + +Add a note under `### Notes`: + +``` +Fixed by removing buyOrderDeposit (NicSpent) and buyOrderPayBack (NicEarned) from +CharacterWallet.OnCommited. TransportAssignmentSubmit double-count also fixed in +the same change. NicSpent for actual market fulfillments is unaffected (handled by +explicit hooks in Market.cs). +``` + +- [ ] **Step 2: Commit** + +``` +git add docs/backlog/issues.md +git commit -m "docs: mark ISSUE-022 as DONE" +``` diff --git a/docs/superpowers/plans/2026-05-28-automarket-admintool.md b/docs/superpowers/plans/2026-05-28-automarket-admintool.md new file mode 100644 index 0000000..bbcaf86 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-automarket-admintool.md @@ -0,0 +1,2034 @@ +# AutoMarket AdminTool Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a four-tab AutoMarket panel (Config, Trade List, Statistics, Orders) to the AdminTool, following the Seasons/EquipmentSets module folder pattern. + +**Architecture:** Module folder `src/Perpetuum.AdminTool/AutoMarket/` holds repository and model types. Per-tab ViewModels live in `ViewModels/`, XAML views in `Views/`. Root `AutoMarketViewModel` owns all tab VMs and the Refresh Now command. No server-side changes — Refresh Now calls SPs directly from AdminTool DB connection. + +**Tech Stack:** .NET 8, C# 12, WPF, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`, `ObservableObject`), Microsoft.Data.SqlClient, SQL Server. + +**Reference spec:** `docs/superpowers/specs/2026-05-28-automarket-admintool-design.md` + +**Reference patterns:** +- `src/Perpetuum.AdminTool/EquipmentSets/` — module folder structure +- `src/Perpetuum.AdminTool/ViewModels/EquipmentSetsViewModel.cs` — VM pattern +- `src/Perpetuum.AdminTool/Views/EquipmentSetsView.xaml` — XAML pattern +- `src/Perpetuum.AdminTool/Editing/RawSqlChange.cs` + `SqlLiteral.cs` — ChangeQueue SQL generation + +--- + +### Task 1: Row model types + label map + internal DTOs + +**Files:** +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketConfigRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketTradeListRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRawMaterialRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketNicFlowRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketGatherRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderData.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AddAutoMarketItemPickItem.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs` + +- [ ] **Step 1: Create AutoMarketConfigRow.cs** + +```csharp +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.AutoMarket +{ + public partial class AutoMarketConfigRow : ObservableObject + { + public string ParamName { get; init; } = ""; + public string Label { get; init; } = ""; + public string Description { get; init; } = ""; + public double OriginalValue { get; set; } + + [ObservableProperty] private double _paramValue; + + public bool IsDirty => Math.Abs(ParamValue - OriginalValue) > 1e-9; + + partial void OnParamValueChanged(double value) => OnPropertyChanged(nameof(IsDirty)); + } +} +``` + +- [ ] **Step 2: Create AutoMarketTradeListRow.cs** + +```csharp +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.AutoMarket +{ + public partial class AutoMarketTradeListRow : ObservableObject + { + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public int OriginalAmount { get; set; } + + [ObservableProperty] private int _amount; + + public bool IsDirty => Amount != OriginalAmount; + + partial void OnAmountChanged(int value) => OnPropertyChanged(nameof(IsDirty)); + } +} +``` + +- [ ] **Step 3: Create AutoMarketRawMaterialRow.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketRawMaterialRow + { + public string RawMaterialName { get; init; } = ""; + public long TotalQuantity { get; init; } + } +} +``` + +- [ ] **Step 4: Create AutoMarketNicFlowRow.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketNicFlowRow + { + public string Period { get; init; } = ""; + public long PlasmaIn { get; init; } + public long RawmatOut { get; init; } + public long NetDelta => PlasmaIn - RawmatOut; + public double? PlasmaBudgetPct { get; init; } + public double? RawmatBudgetPct { get; init; } + } +} +``` + +- [ ] **Step 5: Create AutoMarketPricingTraceRow.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketPricingTraceRow + { + public string ResourceName { get; init; } = ""; + public double PlasmaAnchor { get; init; } + public double SdRatio { get; init; } + public double RiskMultiplier { get; init; } + public double ComputedPrice { get; init; } + public double? StoredPrice { get; init; } + } +} +``` + +- [ ] **Step 6: Create AutoMarketGatherRow.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketGatherRow + { + public string ResourceName { get; init; } = ""; + public long PveQty { get; init; } + public long PvpQty { get; init; } + public long TotalQty => PveQty + PvpQty; + public double PvpPct => TotalQty > 0 ? PvpQty * 100.0 / TotalQty : 0.0; + } +} +``` + +- [ ] **Step 7: Create AutoMarketOrderRow.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketOrderRow + { + public string DisplayName { get; init; } = ""; + public string OrderType { get; init; } = ""; + public double Price { get; init; } + public int Amount { get; init; } + public string MarketName { get; init; } = ""; + public string Category { get; init; } = ""; + } +} +``` + +- [ ] **Step 8: Create AutoMarketOrderData.cs** (internal DTO — repository to VM) + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + internal record AutoMarketOrderData( + int ItemDefinition, + string DefinitionName, + bool IsSell, + double Price, + int Quantity, + string MarketDefinitionName); +} +``` + +- [ ] **Step 9: Create AddAutoMarketItemPickItem.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AddAutoMarketItemPickItem + { + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; init; } = ""; + } +} +``` + +- [ ] **Step 10: Create AutoMarketLabels.cs** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + internal static class AutoMarketLabels + { + internal record LabelMeta(string Label, string Description); + + internal static readonly IReadOnlyDictionary Map = + new Dictionary + { + ["plasma_anchor_fraction"] = new("Plasma Anchor Fraction", "Fraction of alpha plasma price used as raw material pricing anchor"), + ["plasma_buy_qty_fraction"] = new("Plasma Buy Quantity", "Fraction of gathered plasma placed as buy orders"), + ["daily_plasma_budget_nic"] = new("Daily Plasma Budget (NIC)", "Max NIC spent on plasma buy orders per calendar day"), + ["daily_rawmat_budget_nic"] = new("Daily Rawmat Budget (NIC)", "Max NIC spent on raw material buy orders per calendar day"), + ["resource_ds_ratio_min"] = new("S/D Ratio Min", "Lower clamp for supply/demand ratio in pricing formula"), + ["resource_ds_ratio_max"] = new("S/D Ratio Max", "Upper clamp for supply/demand ratio in pricing formula"), + ["product_sell_margin"] = new("Product Sell Margin", "Production item sell orders priced at production_cost × this value"), + ["raw_mat_sell_multiplier"] = new("Rawmat Sell Multiplier", "Raw material sell orders priced at production_cost × this value"), + ["product_buyback_margin"] = new("Product Buyback Margin", "Buyback buy orders priced at production_cost × this value"), + }; + } +} +``` + +- [ ] **Step 11: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 12: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/ +git commit -m "feat: add AutoMarket AdminTool row model types and label map" +``` + +--- + +### Task 2: AutoMarketRepository — Config and Trade List queries + +**Files:** +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` + +- [ ] **Step 1: Create AutoMarketRepository.cs with LoadConfigAsync and trade list methods** + +```csharp +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketRepository + { + private readonly ConnectionSettings _connection; + + public AutoMarketRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadConfigAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT param_name, param_value FROM automarket_config ORDER BY param_name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var value = reader.GetDouble(1); + AutoMarketLabels.Map.TryGetValue(name, out var meta); + result.Add(new AutoMarketConfigRow + { + ParamName = name, + ParamValue = value, + OriginalValue = value, + Label = meta?.Label ?? name, + Description = meta?.Description ?? "", + }); + } + return result; + } + + public async Task> LoadTradeListAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT definitionname, amount FROM market_orders_configuration ORDER BY definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var amount = reader.GetInt32(1); + result.Add(new AutoMarketTradeListRow + { + DefinitionName = name, + DisplayName = name, + Amount = amount, + OriginalAmount = amount, + }); + } + return result; + } + + public async Task> LoadDerivedMaterialsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) " + + "FROM v_required_raw_materials " + + "GROUP BY raw_material " + + "ORDER BY raw_material"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new AutoMarketRawMaterialRow + { + RawMaterialName = reader.GetString(0), + TotalQuantity = reader.GetInt64(1), + }); + return result; + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs +git commit -m "feat: add AutoMarketRepository with Config and Trade List queries" +``` + +--- + +### Task 3: AutoMarketRepository — Statistics queries + +**Files:** +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` + +- [ ] **Step 1: Add LoadNicFlowAsync to AutoMarketRepository** + +Add this method inside `AutoMarketRepository`: + +```csharp +public async Task> LoadNicFlowAsync() +{ + long todayPlasma, weekPlasma, allPlasma; + long todayRawmat, weekRawmat, allRawmat; + double plasmaBudget = 0, rawmatBudget = 0; + + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT " + + " ISNULL(SUM(CASE WHEN sold_on = CAST(GETUTCDATE() AS DATE) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(CASE WHEN sold_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(income), 0) " + + "FROM plasma_sold"; + await using var r = await cmd.ExecuteReaderAsync(); + await r.ReadAsync(); + todayPlasma = (long)r.GetDouble(0); + weekPlasma = (long)r.GetDouble(1); + allPlasma = (long)r.GetDouble(2); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT " + + " ISNULL(SUM(CASE WHEN purchased_on = CAST(GETUTCDATE() AS DATE) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(CASE WHEN purchased_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(income), 0) " + + "FROM rawmat_purchased"; + await using var r = await cmd.ExecuteReaderAsync(); + await r.ReadAsync(); + todayRawmat = (long)r.GetDouble(0); + weekRawmat = (long)r.GetDouble(1); + allRawmat = (long)r.GetDouble(2); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT param_name, param_value FROM automarket_config " + + "WHERE param_name IN ('daily_plasma_budget_nic', 'daily_rawmat_budget_nic')"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + if (r.GetString(0) == "daily_plasma_budget_nic") plasmaBudget = r.GetDouble(1); + if (r.GetString(0) == "daily_rawmat_budget_nic") rawmatBudget = r.GetDouble(1); + } + } + + return new List + { + new() { + Period = "Today", + PlasmaIn = todayPlasma, + RawmatOut = todayRawmat, + PlasmaBudgetPct = plasmaBudget > 0 ? todayPlasma * 100.0 / plasmaBudget : null, + RawmatBudgetPct = rawmatBudget > 0 ? todayRawmat * 100.0 / rawmatBudget : null, + }, + new() { Period = "Last 7 Days", PlasmaIn = weekPlasma, RawmatOut = weekRawmat }, + new() { Period = "All Time", PlasmaIn = allPlasma, RawmatOut = allRawmat }, + }; +} +``` + +- [ ] **Step 2: Add LoadPricingTraceAsync to AutoMarketRepository** + +Add this method inside `AutoMarketRepository`. The formula mirrors `recalculate_raw_material_prices` exactly: + +```csharp +public async Task> LoadPricingTraceAsync() +{ + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // 1. Alpha plasma anchor price + double alphaPlasmaPrice = 0; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT TOP 1 dynamic_price FROM fn_CalculateDynamicPlasmaPrices(1) " + + "WHERE plasma_type = 'def_common_reactor_plasma'"; + await using var r = await cmd.ExecuteReaderAsync(); + if (await r.ReadAsync()) alphaPlasmaPrice = r.IsDBNull(0) ? 0 : r.GetDouble(0); + } + + // 2. Config params + double anchorFraction = 0.15, dsMin = 0.25, dsMax = 4.0; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT param_name, param_value FROM automarket_config " + + "WHERE param_name IN ('plasma_anchor_fraction','resource_ds_ratio_min','resource_ds_ratio_max')"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + switch (r.GetString(0)) + { + case "plasma_anchor_fraction": anchorFraction = r.GetDouble(1); break; + case "resource_ds_ratio_min": dsMin = r.GetDouble(1); break; + case "resource_ds_ratio_max": dsMax = r.GetDouble(1); break; + } + } + } + + var plasmaAnchor = alphaPlasmaPrice * anchorFraction; + + // 3. Supply data (last 7 days from resources_gathered) + record SupplyData(double DailyAvg, long PvpQty, long TotalQty); + var supply = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT resource_name, " + + " SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END), " + + " SUM(quantity), " + + " SUM(quantity) / 7.0 " + + "FROM resources_gathered " + + "WHERE gathered_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) " + + "GROUP BY resource_name"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + supply[r.GetString(0)] = new SupplyData(r.GetDouble(3), r.GetInt64(1), r.GetInt64(2)); + } + + // 4. Demand data + var demand = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) / 7.0 " + + "FROM v_required_raw_materials GROUP BY raw_material"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) demand[r.GetString(0)] = r.GetDouble(1); + } + + // 5. Materials list + var materials = new List(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT DISTINCT raw_material FROM v_required_raw_materials ORDER BY raw_material"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) materials.Add(r.GetString(0)); + } + + // 6. Stored prices (latest week) + var storedPrices = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT resource_name, unit_price FROM resource_market_prices " + + "WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices)"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) storedPrices[r.GetString(0)] = (double)r.GetDecimal(1); + } + + // Compute + var result = new List(); + foreach (var name in materials) + { + supply.TryGetValue(name, out var sup); + double supplyDailyAvg = sup?.DailyAvg ?? 0; + demand.TryGetValue(name, out var dailyDemand); + + double sdRatio = supplyDailyAvg <= 0 + ? dsMax + : Math.Clamp(dailyDemand / supplyDailyAvg, dsMin, dsMax); + + double pvpFraction = (sup != null && sup.TotalQty > 0) + ? (double)sup.PvpQty / sup.TotalQty + : 1.0; + + var riskMultiplier = 1.0 + pvpFraction; + var computedPrice = Math.Round(plasmaAnchor * sdRatio * riskMultiplier, 2); + + result.Add(new AutoMarketPricingTraceRow + { + ResourceName = name, + PlasmaAnchor = Math.Round(plasmaAnchor, 4), + SdRatio = Math.Round(sdRatio, 4), + RiskMultiplier = Math.Round(riskMultiplier, 4), + ComputedPrice = computedPrice, + StoredPrice = storedPrices.TryGetValue(name, out var sp) ? sp : null, + }); + } + return result; +} +``` + +- [ ] **Step 3: Add LoadGatherBreakdownAsync to AutoMarketRepository** + +```csharp +public async Task> LoadGatherBreakdownAsync() +{ + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT resource_name, " + + " SUM(CASE WHEN is_pvp = 0 THEN quantity ELSE 0 END), " + + " SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END) " + + "FROM resources_gathered_daily " + + "WHERE gathered_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) " + + "GROUP BY resource_name " + + "ORDER BY resource_name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new AutoMarketGatherRow + { + ResourceName = reader.GetString(0), + PveQty = reader.GetInt64(1), + PvpQty = reader.GetInt64(2), + }); + return result; +} +``` + +- [ ] **Step 4: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs +git commit -m "feat: add AutoMarketRepository statistics queries (NIC flow, pricing trace, gather)" +``` + +--- + +### Task 4: AutoMarketRepository — Orders + Refresh Now + +**Files:** +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` + +- [ ] **Step 1: Add LoadOrdersAsync to AutoMarketRepository** + +```csharp +public async Task> LoadOrdersAsync() +{ + var productionItems = new HashSet(StringComparer.OrdinalIgnoreCase); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT definitionname FROM market_orders_configuration"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) productionItems.Add(r.GetString(0)); + } + + var result = new List(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT mi.itemdefinition, ISNULL(ed.definitionname,''), mi.isSell, " + + " mi.price, mi.quantity, ISNULL(ed2.definitionname,'') " + + "FROM marketitems mi " + + "LEFT JOIN entitydefaults ed ON ed.definition = mi.itemdefinition " + + "LEFT JOIN entities ent ON ent.eid = mi.marketeid " + + "LEFT JOIN entitydefaults ed2 ON ed2.definition = ent.definition " + + "WHERE mi.isAutoOrder = 1 " + + "ORDER BY ed.definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + result.Add(new AutoMarketOrderData( + r.GetInt32(0), r.GetString(1), r.GetBoolean(2), + r.GetDouble(3), r.GetInt32(4), r.GetString(5))); + } + return result; +} +``` + +- [ ] **Step 2: Add RefreshNowAsync to AutoMarketRepository** + +```csharp +public async Task RefreshNowAsync() +{ + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandTimeout = 120; + cmd.CommandText = "EXEC recalculate_raw_material_prices"; + await cmd.ExecuteNonQueryAsync(); + } + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandTimeout = 120; + cmd.CommandText = "EXEC usp_RefreshAutoMarketOrders"; + await cmd.ExecuteNonQueryAsync(); + } +} +``` + +- [ ] **Step 3: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs +git commit -m "feat: add AutoMarketRepository orders query and RefreshNow SP execution" +``` + +--- + +### Task 5: AutoMarketConfigViewModel + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketConfigViewModel.cs` + +- [ ] **Step 1: Create AutoMarketConfigViewModel.cs** + +```csharp +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketConfigViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection Rows { get; } = new(); + + public AutoMarketConfigViewModel(AutoMarketRepository repo, ChangeQueue queue) + { + _repo = repo; + _queue = queue; + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = ""; + StatusIsError = false; + try + { + var rows = await _repo.LoadConfigAsync(); + Rows.Clear(); + foreach (var r in rows) Rows.Add(r); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + [RelayCommand] + private void QueueSave(AutoMarketConfigRow row) + { + var description = $"automarket_config: update {row.ParamName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + _queue.Add(new RawSqlChange( + description, + $"UPDATE automarket_config SET param_value = {SqlLiteral.Of(row.ParamValue)} " + + $"WHERE param_name = {SqlLiteral.Of(row.ParamName)}")); + row.OriginalValue = row.ParamValue; + StatusMessage = $"{row.Label} queued."; + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/AutoMarketConfigViewModel.cs +git commit -m "feat: add AutoMarketConfigViewModel" +``` + +--- + +### Task 6: AddAutoMarketItemViewModel + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AddAutoMarketItemViewModel.cs` + +- [ ] **Step 1: Create AddAutoMarketItemViewModel.cs** + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddAutoMarketItemViewModel : ObservableObject + { + private const int EnglishLangId = 0; + + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private AddAutoMarketItemPickItem? _selectedItem; + [ObservableProperty] private string _errorMessage = ""; + + public ObservableCollection Items { get; } = new(); + public ICollectionView View { get; } + + public AddAutoMarketItemViewModel( + LookupCache lookups, + TranslationsViewModel? translations, + IReadOnlySet alreadyInList) + { + var store = translations?.Store; + foreach (var e in lookups.Entities) + { + if (!e.Enabled) continue; + if (alreadyInList.Contains(e.Name)) continue; + + var translated = ""; + if (store != null) + { + var row = store.Rows.FirstOrDefault(r => r.Key == e.Name); + translated = row?[EnglishLangId] ?? ""; + } + + Items.Add(new AddAutoMarketItemPickItem + { + Definition = e.Definition, + DefinitionName = e.Name, + DisplayName = string.IsNullOrEmpty(translated) ? e.Name : translated, + }); + } + + View = CollectionViewSource.GetDefaultView(Items); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + private bool MatchesFilter(object obj) + { + if (obj is not AddAutoMarketItemPickItem item) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + return item.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase) + || item.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/AddAutoMarketItemViewModel.cs +git commit -m "feat: add AddAutoMarketItemViewModel for item picker dialog" +``` + +--- + +### Task 7: AutoMarketTradeListViewModel + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketTradeListViewModel.cs` + +- [ ] **Step 1: Create AutoMarketTradeListViewModel.cs** + +```csharp +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Translations; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketTradeListViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + private readonly TranslationsViewModel? _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection Rows { get; } = new(); + public ObservableCollection DerivedMaterials { get; } = new(); + + public AutoMarketTradeListViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + LookupCache lookups, + TranslationsViewModel? translations) + { + _repo = repo; + _queue = queue; + _lookups = lookups; + _translations = translations; + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = ""; + StatusIsError = false; + try + { + var store = _translations?.Store; + var rows = await _repo.LoadTradeListAsync(); + Rows.Clear(); + foreach (var r in rows) + { + if (store != null) + { + var tr = store.Rows.FirstOrDefault(x => x.Key == r.DefinitionName); + var t = tr?[EnglishLangId]; + if (!string.IsNullOrEmpty(t)) r.DisplayName = t; + } + Rows.Add(r); + } + await RefreshDerivedAsync(); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private async Task RefreshDerivedAsync() + { + try + { + var mats = await _repo.LoadDerivedMaterialsAsync(); + DerivedMaterials.Clear(); + foreach (var m in mats) DerivedMaterials.Add(m); + } + catch { /* non-fatal — sub-panel stays empty */ } + } + + [RelayCommand] + private void QueueSave(AutoMarketTradeListRow row) + { + var description = $"market_orders_configuration: update {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + _queue.Add(new RawSqlChange( + description, + $"UPDATE market_orders_configuration SET amount = {SqlLiteral.Of(row.Amount)} " + + $"WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}")); + row.OriginalAmount = row.Amount; + StatusMessage = $"{row.DisplayName} amount queued."; + } + + [RelayCommand] + private void Remove(AutoMarketTradeListRow row) + { + var msg = $"Remove '{row.DisplayName}' from the trade list?\n\n" + + "AutoMarket will no longer place orders for this item."; + if (MessageBox.Show(msg, "Remove item", + MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) + != MessageBoxResult.Yes) return; + + // Cancel any pending save for this row + var saveDesc = $"market_orders_configuration: update {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == saveDesc); + if (existing != null) _queue.Items.Remove(existing); + + _queue.Add(new RawSqlChange( + $"market_orders_configuration: delete {row.DefinitionName}", + $"DELETE FROM market_orders_configuration WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}", + isDestructive: true)); + Rows.Remove(row); + StatusMessage = $"'{row.DisplayName}' queued for removal."; + } + + public void AddItem(Window owner) + { + var existing = Rows.Select(r => r.DefinitionName).ToHashSet(); + var vm = new AddAutoMarketItemViewModel(_lookups, _translations, existing); + var win = new AddAutoMarketItemWindow(vm) { Owner = owner }; + if (win.ShowDialog() != true || vm.SelectedItem == null) return; + + var item = vm.SelectedItem; + _queue.Add(new RawSqlChange( + $"market_orders_configuration: insert {item.DefinitionName}", + $"INSERT INTO market_orders_configuration (definitionname, amount) " + + $"VALUES ({SqlLiteral.Of(item.DefinitionName)}, 1)")); + + Rows.Add(new AutoMarketTradeListRow + { + DefinitionName = item.DefinitionName, + DisplayName = item.DisplayName, + Amount = 1, + OriginalAmount = 1, + }); + StatusMessage = $"'{item.DisplayName}' queued for insert."; + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. If `AddAutoMarketItemWindow` is not yet defined the build will fail — note that class is created in Task 12. To unblock the build, forward-declare the class as `public partial class AddAutoMarketItemWindow : System.Windows.Window { }` in a temporary stub, or implement Task 12 first. When Task 12 is done, remove the stub. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/AutoMarketTradeListViewModel.cs +git commit -m "feat: add AutoMarketTradeListViewModel" +``` + +--- + +### Task 8: AutoMarketStatisticsViewModel + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs` + +- [ ] **Step 1: Create AutoMarketStatisticsViewModel.cs** + +```csharp +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketStatisticsViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection NicFlow { get; } = new(); + public ObservableCollection PricingTrace { get; } = new(); + public ObservableCollection GatherBreakdown { get; } = new(); + + public AutoMarketStatisticsViewModel(AutoMarketRepository repo) => _repo = repo; + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading statistics..."; + StatusIsError = false; + try + { + var nicTask = _repo.LoadNicFlowAsync(); + var priceTask = _repo.LoadPricingTraceAsync(); + var gatherTask = _repo.LoadGatherBreakdownAsync(); + await Task.WhenAll(nicTask, priceTask, gatherTask); + + NicFlow.Clear(); + foreach (var r in nicTask.Result) NicFlow.Add(r); + PricingTrace.Clear(); + foreach (var r in priceTask.Result) PricingTrace.Add(r); + GatherBreakdown.Clear(); + foreach (var r in gatherTask.Result) GatherBreakdown.Add(r); + + StatusMessage = $"Loaded at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshAsyncCommand.NotifyCanExecuteChanged(); + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs +git commit -m "feat: add AutoMarketStatisticsViewModel" +``` + +--- + +### Task 9: AutoMarketOrdersViewModel + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketOrdersViewModel.cs` + +- [ ] **Step 1: Create AutoMarketOrdersViewModel.cs** + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketOrdersViewModel : ObservableObject + { + private static readonly HashSet PlasmaIds = new() { 3271, 3272, 3273, 3274 }; + private const int EnglishLangId = 0; + + private readonly AutoMarketRepository _repo; + private readonly TranslationsViewModel? _translations; + private List _allOrders = new(); + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string? _orderTypeFilter; + [ObservableProperty] private string? _categoryFilter; + + public ObservableCollection FilteredOrders { get; } = new(); + + public static IReadOnlyList OrderTypeOptions { get; } = + new List { null, "Buy", "Sell", "Buyback" }; + public static IReadOnlyList CategoryOptions { get; } = + new List { null, "Plasma", "Raw Material", "Production Item" }; + + public AutoMarketOrdersViewModel(AutoMarketRepository repo, TranslationsViewModel? translations) + { + _repo = repo; + _translations = translations; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading orders..."; + StatusIsError = false; + try + { + var raw = await _repo.LoadOrdersAsync(); + var store = _translations?.Store; + + string Translate(string defName) + { + if (string.IsNullOrEmpty(defName) || store == null) return defName; + var row = store.Rows.FirstOrDefault(r => r.Key == defName); + var t = row?[EnglishLangId]; + return string.IsNullOrEmpty(t) ? defName : t; + } + + // Load production item names to classify categories + // (LoadOrdersAsync already fetches definitionnames that are in market_orders_configuration + // via its own query — we classify in the VM using the same SP logic.) + // However the repository returns raw data without the productionItems set. + // Re-query just the production item names: + var prodItems = (await _repo.LoadTradeListAsync()) + .Select(r => r.DefinitionName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + _allOrders = raw.Select(d => + { + var category = PlasmaIds.Contains(d.ItemDefinition) ? "Plasma" + : prodItems.Contains(d.DefinitionName) ? "Production Item" + : "Raw Material"; + var orderType = d.IsSell ? "Sell" + : category == "Production Item" ? "Buyback" + : "Buy"; + return new AutoMarketOrderRow + { + DisplayName = Translate(d.DefinitionName), + OrderType = orderType, + Price = d.Price, + Amount = d.Quantity, + MarketName = Translate(d.MarketDefinitionName), + Category = category, + }; + }).ToList(); + + ApplyFilter(); + StatusMessage = $"Loaded {_allOrders.Count} order(s) at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool _) => RefreshAsyncCommand.NotifyCanExecuteChanged(); + partial void OnOrderTypeFilterChanged(string? _) => ApplyFilter(); + partial void OnCategoryFilterChanged(string? _) => ApplyFilter(); + + private void ApplyFilter() + { + var filtered = _allOrders.AsEnumerable(); + if (OrderTypeFilter != null) filtered = filtered.Where(r => r.OrderType == OrderTypeFilter); + if (CategoryFilter != null) filtered = filtered.Where(r => r.Category == CategoryFilter); + FilteredOrders.Clear(); + foreach (var r in filtered) FilteredOrders.Add(r); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/AutoMarketOrdersViewModel.cs +git commit -m "feat: add AutoMarketOrdersViewModel" +``` + +--- + +### Task 10: AutoMarketViewModel (root) + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs` + +- [ ] **Step 1: Create AutoMarketViewModel.cs** + +```csharp +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + + [ObservableProperty] private bool _isRefreshing; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public AutoMarketConfigViewModel Config { get; } + public AutoMarketTradeListViewModel TradeList { get; } + public AutoMarketStatisticsViewModel Statistics { get; } + public AutoMarketOrdersViewModel Orders { get; } + + public AutoMarketViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + LookupCache lookups, + TranslationsViewModel? translations = null) + { + _repo = repo; + Config = new AutoMarketConfigViewModel(repo, queue); + TradeList = new AutoMarketTradeListViewModel(repo, queue, lookups, translations); + Statistics = new AutoMarketStatisticsViewModel(repo); + Orders = new AutoMarketOrdersViewModel(repo, translations); + } + + public async Task LoadAsync() + { + await Task.WhenAll(Config.LoadAsync(), TradeList.LoadAsync()); + } + + [RelayCommand(CanExecute = nameof(CanRefreshNow))] + private async Task RefreshNow() + { + IsRefreshing = true; + StatusIsError = false; + StatusMessage = "Refreshing AutoMarket orders..."; + try + { + await _repo.RefreshNowAsync(); + StatusMessage = $"Refresh complete at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Refresh failed: {ex.Message}"; + } + finally { IsRefreshing = false; } + } + + private bool CanRefreshNow() => !IsRefreshing; + partial void OnIsRefreshingChanged(bool value) => RefreshNowCommand.NotifyCanExecuteChanged(); + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs +git commit -m "feat: add AutoMarketViewModel root with RefreshNow command" +``` + +--- + +### Task 11: XAML Views — root shell + Config + +**Files:** +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketView.xaml` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketView.xaml.cs` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketConfigView.xaml` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketConfigView.xaml.cs` + +- [ ] **Step 1: Create AutoMarketView.xaml** + +```xml + + + + + + +