diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index 79d49376..03c30cb4 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -1,6 +1,161 @@ # Last ID used -039 +042 + +## IMPROVEMENT-042 - AutoMarket: Per-Item Order Type Control on Trade List + +Status: DONE +Priority: CRITICAL +Area: AutoMarket / Economy + +### Implementation Summary + +Implemented on branch `p36.6`. + +- **DB migration:** `docs/db_structure/migrations/IMPROVEMENT-042-trade-list-order-type.sql` — adds `create_sell_orders BIT NOT NULL DEFAULT 1` and `create_buyback_orders BIT NOT NULL DEFAULT 1` to `market_orders_configuration` (idempotent, both default to 1 to preserve existing behaviour); updates `usp_RefreshAutoMarketOrders` with `WHERE moc.create_sell_orders = 1` on Step 3 and `WHERE moc.create_buyback_orders = 1` on Step 6. +- **SP doc snapshot:** `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` updated to match. +- **`AutoMarketTradeListRow`:** added `CreateSellOrders bool`, `CreateBuybackOrders bool` observable properties; `OriginalCreate*` originals for dirty tracking; `IsDirty` updated to cover all three fields. +- **`AutoMarketRepository.LoadTradeListAsync`:** SELECT expanded to include both new columns; row construction reads and sets all four new properties. +- **`AutoMarketTradeListViewModel.QueueSave`:** UPDATE SQL now includes all three SET columns; originals reset after queuing. `AddItem`: new row defaults both flags to `true`. +- **`AutoMarketTradeListView.xaml`:** two `DataGridTemplateColumn` checkbox columns added between Amount and Queue Save — "Sell Orders" (bound to `CreateSellOrders`) and "Buyback Orders" (bound to `CreateBuybackOrders`). + +Design spec: `docs/superpowers/specs/2026-06-10-trade-list-order-type-design.md` +Implementation plan: `docs/superpowers/plans/2026-06-10-trade-list-order-type.md` + +### Problem + +The AutoMarket trade list currently creates both buy and sell orders for every configured item. There is no way to control per item whether the system should place a buy order, a sell order, both, or neither. This matters for items where only one direction makes economic sense (e.g. sinks that should only be bought back, or items that should only be sold to players but not repurchased). + +### Notes + +- Default value must be `Both` so existing trade list entries are unaffected after migration. +- Similar in spirit to the per-item override pattern introduced in IMPROVEMENT-040 for raw materials. + +--- + +## IMPROVEMENT-041 - AdminTool Economy: Corporation Tag on Money Supply + Top-10 Wealthiest Corporations + +Status: DONE +Priority: HIGH +Area: AdminTool / Economy + +### Implementation Summary + +Implemented on branch `p36.6`. + +- **`EconomyWealthRow`:** added `CorpTag` property (empty string for unguilded/default-corp characters). +- **`EconomyCorporationWealthRow`:** new model with `Rank`, `Name`, `Tag`, `MemberCount`, `CorpWallet`, `MemberAggregate`, `Combined` (computed). +- **`EconomyMoneySupplyData`:** added `Top10CorpRows`. +- **`EconomyMoneySupplyRepository`:** `LoadTop10Async` updated to use a correlated subquery for corp tag (avoids row duplication from non-unique `corporationmembers.memberid`); new `LoadTop10CorpAsync` queries all non-default active corps, ordered by combined wealth. +- **`EconomyMoneySupplyViewModel`:** added `Top10CorpRows` collection, populated in `RefreshAsync`. +- **`EconomyMoneySupplyView.xaml`:** `Corp` column added to character DataGrid; new Top-10 Corporations DataGrid appended. +- No schema changes. No server-side code touched. + +### Problem + +The Money Supply panel shows top-10 wealthiest characters but lacks context about their corporation membership. Additionally, there is no equivalent view for corporations — the wealthiest corporations and their composition are invisible. + +### Proposed Fix + +1. **Character money supply table** — add a `Corporation Tag` column showing the 4-character corp tag (or blank if NPC/unguilded) next to each character row. +2. **New top-10 wealthiest corporations section** — query the sum of all member wallets (and/or the corp wallet itself) per corporation; display corporation name, tag, and member count. + +### Notes + +- Confirm whether "wealthiest corporation" means the corporate wallet balance, the aggregate of member wallets, or both. +- Identify the relevant DB tables/views (`corporations`, `characters`, `wallet` or equivalent) before writing queries. +- Keep to existing AdminTool MVVM patterns — thin VM, no business logic leakage. + +--- + +## IMPROVEMENT-040 - AutoMarket: Decouple Raw Material Coverage from Trade List + +Status: DONE +Priority: CRITICAL +Area: AutoMarket / Economy + +### Implementation Summary + +Implemented on branch `p36.6` (commits `715a43d`–`1d1dfa9`). + +- **DB migration:** `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql` — creates `automarket_rawmat_overrides`, `automarket_rawmat_weekly_tracking`, inserts `weekly_rawmat_cap_default = 500000000`, adds `IX_rmp_on_name`, renames view, creates `sp_RecordRawMatWeeklyPurchased` +- **View rename:** `v_required_raw_materials` → `v_trade_list_raw_material_demand` (demand signal only) +- **`v_all_production_costs`:** `raw_resources` CTE now scans entitydefaults directly (cf_raw_material bitmask) +- **`recalculate_raw_material_prices`:** material enumeration expanded to all cf_raw_material items +- **`usp_RefreshAutoMarketOrders`:** `#covered_rawmats` replaces `#raw_materials`; Steps 4+5 are cap-driven +- **`Market.cs`:** `sp_RecordRawMatWeeklyPurchased` called at 3 `FulfillSellOrderInstantly` sites +- **AdminTool:** Raw Materials tab (VM + View), Statistics Pricing Trace columns, repository updates +- **Recipe-graph demand signal:** analysed and rejected — C-only approach (gather-volume proxy) chosen; max scarcity for ungathered materials is self-correcting on a low-population server + +### Problem + +The current AutoMarket system identifies raw materials exclusively by recursively exploding items in `market_orders_configuration` (the trade list). This creates tight coupling: materials for items outside the trade list get no market support, and any newly added craftable item requires a manual trade list update before its raw material supply chain becomes active. The trade list's role is also overloaded — it currently drives both finished product orders and raw material demand calculations. + +### Proposed Architecture + +Decouple raw material coverage from the trade list: + +- **Raw materials** — identified from `entitydefaults` (not from the trade list). Prices calculated independently. Infinite-style buy/sell orders placed for all qualifying materials with a **configurable weekly cap per material** (see Impact Analysis below). +- **Trade list** — scoped to finished product buy/sell/buyback orders only. Product prices derived from raw material prices (cost-plus), not set independently. + +This inverts the current dependency: + +``` +Current: trade list → raw material identification → raw material prices → orders +Proposed: entitydefaults → raw material prices → orders (capped) + trade list + raw material prices → product prices → orders +``` + +### Raw Material Coverage Filter + +Use `entitydefaults` to enumerate qualifying raw materials. Filter criteria (exact requirements TBD during implementation): + +- `enabled = 1` +- `hidden = 0` +- Category matches raw material category flag(s) — filtered by category ID exact match or category tree traversal (children of raw material category nodes) + +Avoids coverage explosion from legacy/unobtainable items while automatically including newly added materials that meet the criteria. + +### Price Calculation + +Retain and extend the existing formula from IMPROVEMENT-030: + +``` +price = plasma_anchor × supply_demand_ratio × pvp_risk_multiplier +``` + +**PvP risk multiplier:** Preserved as-is. Materials gathered predominantly in PvP zones retain their risk premium. + +**Supply/demand ratio:** Retain the existing formula (`daily_demand / daily_supply_avg`, clamped to `[ds_ratio_min, ds_ratio_max]`). Investigate whether adding recipe-graph-derived demand (from the `components` table) as a supplementary signal to the S/D ratio improves pricing accuracy. If the analysis shows negligible benefit (e.g. because recipe demand is already implicit in gather volume on a functioning server), this addition may be skipped. Document the decision. + +**Recalculation cadence:** Daily, same as the existing 24-hour refresh cycle introduced in IMPROVEMENT-030. Startup-only recalculation was considered and rejected — prices must track the live economy between restarts. + +### Weekly Cap Per Material — Impact Analysis Required + +Replace the current arrangement (fixed 10,000,000 quantity for sell orders; budget-capped buy orders) with a **configurable weekly quantity cap per material**. Before implementation, analyze: + +1. **NIC injection bound** — what weekly cap value keeps raw material buy-side NIC injection comparable to or lower than the current `daily_rawmat_budget_nic` regime? +2. **Supply adequacy** — does a weekly cap prevent the market from running dry for high-demand materials during active play periods? +3. **Per-material vs global cap** — whether a single global cap or per-category/per-material overrides are needed for balance. +4. **Interaction with daily budget** — determine whether the weekly cap replaces or works alongside the existing daily NIC budget guard. + +The daily NIC budget cap must remain as a hard guardrail until the impact analysis confirms the weekly cap is safe. + +### Affected Systems + +- `recalculate_raw_material_prices` stored procedure — extend material enumeration to use `entitydefaults` filter +- `usp_RefreshAutoMarketOrders` — step 4 (raw material buy orders) and step 5 (raw material sell orders) reworked +- `v_required_raw_materials` view — may be retired or repurposed as a product-cost calculation helper +- `automarket_config` table — add `weekly_rawmat_cap_per_material` and category filter parameters +- AdminTool AutoMarket panel (IMPROVEMENT-031) — expose new cap config and coverage filter parameters + +### Notes + +- Cross-reference IMPROVEMENT-030 (AutoMarket overhaul) — builds on its pricing formula and config table. +- Cross-reference IMPROVEMENT-031 (AutoMarket AdminTool) — Config tab and Statistics tab need updates for new parameters. +- Cross-reference IMPROVEMENT-035 (player order signal) — raw material coverage expansion increases the surface area where player orders could manipulate S/D ratios; revisit IMPROVEMENT-035 deferral conditions after this is shipped. +- The recipe-graph demand signal analysis (S/D ratio extension) should be done before coding the pricing procedure — if the analysis is inconclusive or shows risk, skip it and document why. +- Category flag filter criteria (exact category IDs and whether to include children) must be confirmed against `entitydefaults` live data before generating the SQL filter. ## IMPROVEMENT-039 - Add economy health statistics beyond NIC flow reporting diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index edffe11e..1001363d 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -31,6 +31,8 @@ Generated from DBML structure. - [artifacttypes](#artifacttypes) - [attributeFlags](#attributeflags) - [automarket_config](#automarket-config) +- [automarket_rawmat_overrides](#automarket-rawmat-overrides) +- [automarket_rawmat_weekly_tracking](#automarket-rawmat-weekly-tracking) - [automarket_unbought_resources](#automarket-unbought-resources) - [automarket_unsold_leftovers](#automarket-unsold-leftovers) - [beams](#beams) @@ -1014,7 +1016,41 @@ Generated from DBML structure. | `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. | +| `daily_rawmat_budget_nic` | `5000000` | Max NIC spent on raw material buy orders per UTC calendar day (0 = unlimited). | +| `weekly_rawmat_cap_default` | `500000000` | Default weekly buy quantity cap per raw material. 0 = unlimited. | + +--- + +## automarket_rawmat_overrides + +**Schema:** `dbo` + +Per-material overrides for AutoMarket raw material coverage. Materials with no row use global defaults from `automarket_config`. + +### Columns + +| Column | Definition | +|---|---| +| `definitionname` | `varchar(100) [not null, pk]` | +| `weekly_cap_override` | `int [null]` — NULL = use global default; 0 = unlimited | +| `create_buy_orders` | `bit [not null, default: 1]` | +| `create_sell_orders` | `bit [not null, default: 1]` | + +--- + +## automarket_rawmat_weekly_tracking + +**Schema:** `dbo` + +Tracks units of each raw material purchased via AutoMarket buy orders per week. Written by `sp_RecordRawMatWeeklyPurchased`. Rolled up by `usp_RefreshAutoMarketOrders` to enforce the per-material weekly cap. Cleaned up at 90-day rolling window. + +### Columns + +| Column | Definition | +|---|---| +| `week_start` | `date [not null, pk]` — Monday of the ISO week | +| `definitionname` | `varchar(100) [not null, pk]` | +| `qty_purchased` | `bigint [not null, default: 0]` | --- diff --git a/docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql b/docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql new file mode 100644 index 00000000..de44d29d --- /dev/null +++ b/docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql @@ -0,0 +1,106 @@ +-- docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql +-- IMPROVEMENT-040: AutoMarket Raw Material Decoupling +-- Run against perpetuumsa while server is ONLINE (no data migration needed). +-- Apply in order — objects are dependencies of later steps. +-- DEPLOY ORDER: After running this migration, ALSO apply (in order): +-- 1. docs/db_structure/views/v_trade_list_raw_material_demand.sql (CREATE OR ALTER VIEW) +-- 2. docs/db_structure/views/v_all_production_costs.sql (CREATE OR ALTER VIEW) +-- 3. docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql +-- 4. docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql +-- 5. docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql + +USE [perpetuumsa]; +GO + +-------------------------------------------------------------------- +-- 1. New table: per-material AutoMarket overrides +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.automarket_rawmat_overrides', 'U') IS NULL +BEGIN + CREATE TABLE dbo.automarket_rawmat_overrides ( + definitionname VARCHAR(100) NOT NULL, + weekly_cap_override INT NULL, -- NULL = use global default; 0 = unlimited + create_buy_orders BIT NOT NULL DEFAULT 1, + create_sell_orders BIT NOT NULL DEFAULT 1, + CONSTRAINT PK_rawmat_overrides PRIMARY KEY CLUSTERED (definitionname) + ); + PRINT 'Created automarket_rawmat_overrides'; +END +GO + +-------------------------------------------------------------------- +-- 2. New table: weekly quantity tracking per raw material +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.automarket_rawmat_weekly_tracking', 'U') IS NULL +BEGIN + CREATE TABLE dbo.automarket_rawmat_weekly_tracking ( + week_start DATE NOT NULL, + definitionname VARCHAR(100) NOT NULL, + qty_purchased BIGINT NOT NULL DEFAULT 0, + CONSTRAINT PK_rawmat_weekly PRIMARY KEY CLUSTERED (week_start, definitionname) + ); + PRINT 'Created automarket_rawmat_weekly_tracking'; +END +GO + +-------------------------------------------------------------------- +-- 3. New automarket_config row: weekly_rawmat_cap_default +-------------------------------------------------------------------- +IF NOT EXISTS (SELECT 1 FROM dbo.automarket_config WHERE param_name = 'weekly_rawmat_cap_default') +BEGIN + INSERT INTO dbo.automarket_config (param_name, param_value) + VALUES ('weekly_rawmat_cap_default', 500000000); + PRINT 'Inserted weekly_rawmat_cap_default into automarket_config'; +END +GO + +-------------------------------------------------------------------- +-- 4. Index on resource_market_prices(calculated_on, resource_name) +-- Required for efficient MERGE in recalculate_raw_material_prices +-- after material list expands. +-------------------------------------------------------------------- +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.resource_market_prices') + AND name = 'IX_rmp_on_name' +) +BEGIN + CREATE NONCLUSTERED INDEX IX_rmp_on_name + ON dbo.resource_market_prices (calculated_on, resource_name); + PRINT 'Created IX_rmp_on_name on resource_market_prices'; +END +GO + +-------------------------------------------------------------------- +-- 5. Rename view: v_required_raw_materials → v_trade_list_raw_material_demand +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.v_required_raw_materials', 'V') IS NOT NULL + AND OBJECT_ID('dbo.v_trade_list_raw_material_demand', 'V') IS NULL +BEGIN + EXEC sp_rename 'dbo.v_required_raw_materials', 'v_trade_list_raw_material_demand'; + PRINT 'Renamed v_required_raw_materials to v_trade_list_raw_material_demand'; +END +GO + +-------------------------------------------------------------------- +-- 6. New stored procedure: sp_RecordRawMatWeeklyPurchased +-------------------------------------------------------------------- +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatWeeklyPurchased] + @week_start DATE, + @definitionname VARCHAR(100), + @quantity BIGINT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.automarket_rawmat_weekly_tracking WITH (HOLDLOCK) AS target + USING (SELECT @week_start, @definitionname, @quantity) + AS source(week_start, definitionname, qty_purchased) + ON target.week_start = source.week_start + AND target.definitionname = source.definitionname + WHEN MATCHED THEN + UPDATE SET qty_purchased = target.qty_purchased + source.qty_purchased + WHEN NOT MATCHED THEN + INSERT (week_start, definitionname, qty_purchased) + VALUES (source.week_start, source.definitionname, source.qty_purchased); +END; +GO diff --git a/docs/db_structure/migrations/IMPROVEMENT-042-trade-list-order-type.sql b/docs/db_structure/migrations/IMPROVEMENT-042-trade-list-order-type.sql new file mode 100644 index 00000000..361619b6 --- /dev/null +++ b/docs/db_structure/migrations/IMPROVEMENT-042-trade-list-order-type.sql @@ -0,0 +1,330 @@ +-- IMPROVEMENT-042: Add per-item order type control to market_orders_configuration. +-- Apply while server is ONLINE (column addition with defaults is non-blocking). +-- Apply BEFORE deploying AdminTool changes. + +USE [perpetuumsa]; +GO + +-- 1. Add columns (idempotent) +IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.market_orders_configuration') + AND name = 'create_sell_orders' +) + ALTER TABLE dbo.market_orders_configuration + ADD create_sell_orders BIT NOT NULL DEFAULT 1; + +IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.market_orders_configuration') + AND name = 'create_buyback_orders' +) + ALTER TABLE dbo.market_orders_configuration + ADD create_buyback_orders BIT NOT NULL DEFAULT 1; +GO + +-- 2. Update stored procedure (idempotent — CREATE OR ALTER) +CREATE OR ALTER PROCEDURE [dbo].[usp_RefreshAutoMarketOrders] +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRY + DECLARE @marketeid BIGINT; + DECLARE @vendoreid BIGINT; + + -- Step 1: Remove old auto orders + DELETE FROM marketitems WHERE isAutoOrder = 1; + + -- Materialise expensive recursive-CTE views once so Steps 3-6 do not re-evaluate them. + SELECT product, production_cost_nic + INTO #prod_costs + FROM v_all_production_costs; + + CREATE INDEX IX_pc_product ON #prod_costs (product); + + -- Week start: Monday of current UTC week (matches recalculate_raw_material_prices formula) + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); + + DECLARE @weekly_cap_default BIGINT = ( + SELECT CAST(param_value AS BIGINT) FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default' + ); + + -- All qualifying raw materials with effective cap and buy/sell flags. + -- Materialized once; Steps 4 and 5 both read from this table. + SELECT + ed.definition, + ed.definitionname, + CASE + WHEN o.weekly_cap_override IS NOT NULL THEN CAST(o.weekly_cap_override AS BIGINT) + ELSE @weekly_cap_default + END AS effective_weekly_cap, -- 0 = unlimited + ISNULL(o.create_buy_orders, 1) AS create_buy_orders, + ISNULL(o.create_sell_orders, 1) AS create_sell_orders + INTO #covered_rawmats + FROM entitydefaults ed + LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname + WHERE ed.categoryflags IN (0x10114, 0x20114, 0x40114) -- cf_organic, cf_ore, cf_liquid + AND ed.enabled = 1 + AND ed.hidden = 0; + + CREATE INDEX IX_crm_def ON #covered_rawmats (definition); + CREATE INDEX IX_crm_name ON #covered_rawmats (definitionname); + + -- Weekly purchases so far for the current week, per material. + SELECT definitionname, ISNULL(SUM(qty_purchased), 0) AS qty_this_week + INTO #weekly_purchased + FROM automarket_rawmat_weekly_tracking + WHERE week_start >= @week_start + GROUP BY definitionname; + + CREATE INDEX IX_wp_name ON #weekly_purchased (definitionname); + + -- Budget and config params + DECLARE @buy_qty_fraction FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'plasma_buy_qty_fraction' + ); + DECLARE @daily_budget FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'daily_plasma_budget_nic' + ); + DECLARE @today_spent FLOAT = ISNULL( + (SELECT SUM(income) FROM plasma_sold WHERE sold_on = CAST(GETUTCDATE() AS DATE)), + 0 + ); + DECLARE @remaining_budget FLOAT = @daily_budget - @today_spent; + + DECLARE @daily_rawmat_budget FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'daily_rawmat_budget_nic' + ); + DECLARE @rawmat_spent FLOAT = ISNULL( + (SELECT SUM(income) FROM rawmat_purchased WHERE purchased_on = CAST(GETUTCDATE() AS DATE)), + 0 + ); + DECLARE @remaining_rawmat_budget FLOAT = @daily_rawmat_budget - @rawmat_spent; + + DECLARE @product_sell_margin FLOAT = (SELECT param_value FROM automarket_config WHERE param_name = 'product_sell_margin'); + DECLARE @raw_mat_sell_multiplier FLOAT = (SELECT param_value FROM automarket_config WHERE param_name = 'raw_mat_sell_multiplier'); + DECLARE @product_buyback_margin FLOAT = (SELECT param_value FROM automarket_config WHERE param_name = 'product_buyback_margin'); + + -- Step 1.1: Alpha plasma buy orders (set-based) + ;WITH AlphaMarkets AS ( + SELECT e.eid + FROM dbo.entities e + JOIN dbo.zoneentities ze ON ze.eid = e.eid + JOIN dbo.zones z ON z.id = ze.zoneID + WHERE e.definition IN ( + SELECT definition FROM dbo.getDefinitionByCFString('cf_public_docking_base') + ) + AND z.terraformable = 0 + AND z.protected = 1 + ), + Markets AS ( + SELECT eid FROM dbo.entities + WHERE definition = 10 AND parent IN (SELECT eid FROM AlphaMarkets) + ), + AlphaOrders AS ( + SELECT + m.eid AS marketeid, + ed.definition AS itemdefinition, + v.vendorEID AS submittereid, + cdp.dynamic_price AS unit_price, + CASE + WHEN cdp.dynamic_price <= 0 OR @remaining_budget <= 0 THEN 0 + WHEN CAST(cdp.gathered * @buy_qty_fraction AS BIGINT) + <= CAST(@remaining_budget / cdp.dynamic_price AS BIGINT) + THEN CAST(cdp.gathered * @buy_qty_fraction AS BIGINT) + ELSE CAST(@remaining_budget / cdp.dynamic_price AS BIGINT) + END AS order_qty + FROM dbo.fn_CalculateDynamicPlasmaPrices(1) cdp + JOIN dbo.entitydefaults ed ON cdp.plasma_type = ed.definitionname + CROSS JOIN Markets m + JOIN dbo.vendors v ON m.eid = v.marketEID + ) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT marketeid, itemdefinition, submittereid, 0, 0, unit_price, order_qty, 1, 1 + FROM AlphaOrders + WHERE order_qty > 0; + + -- Step 1.2: Beta plasma buy orders (set-based) + ;WITH BetaMarkets AS ( + SELECT e.eid + FROM dbo.entities e + JOIN dbo.zoneentities ze ON ze.eid = e.eid + JOIN dbo.zones z ON z.id = ze.zoneID + WHERE e.definition IN ( + SELECT definition FROM dbo.getDefinitionByCFString('cf_public_docking_base') + ) + AND z.terraformable = 0 + AND z.protected = 0 + ), + Markets AS ( + SELECT eid FROM dbo.entities + WHERE definition = 10 AND parent IN (SELECT eid FROM BetaMarkets) + ), + BetaOrders AS ( + SELECT + m.eid AS marketeid, + ed.definition AS itemdefinition, + v.vendorEID AS submittereid, + cdp.dynamic_price AS unit_price, + CASE + WHEN cdp.dynamic_price <= 0 OR @remaining_budget <= 0 THEN 0 + WHEN CAST(cdp.gathered * @buy_qty_fraction AS BIGINT) + <= CAST(@remaining_budget / cdp.dynamic_price AS BIGINT) + THEN CAST(cdp.gathered * @buy_qty_fraction AS BIGINT) + ELSE CAST(@remaining_budget / cdp.dynamic_price AS BIGINT) + END AS order_qty + FROM dbo.fn_CalculateDynamicPlasmaPrices(2) cdp + JOIN dbo.entitydefaults ed ON cdp.plasma_type = ed.definitionname + CROSS JOIN Markets m + JOIN dbo.vendors v ON m.eid = v.marketEID + ) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT marketeid, itemdefinition, submittereid, 0, 0, unit_price, order_qty, 1, 1 + FROM BetaOrders + WHERE order_qty > 0; + + -- Step 1.3: Gamma plasma buy orders (set-based, no vendor EID) + ;WITH GammaMarkets AS ( + SELECT eid FROM dbo.getLiveGammaDockingBases() + ), + Markets AS ( + SELECT eid FROM dbo.entities + WHERE definition = 10 AND parent IN (SELECT eid FROM GammaMarkets) + ), + GammaOrders AS ( + SELECT + m.eid AS marketeid, + ed.definition AS itemdefinition, + cdp.dynamic_price AS unit_price, + CASE + WHEN cdp.dynamic_price <= 0 OR @remaining_budget <= 0 THEN 0 + WHEN CAST(cdp.gathered * @buy_qty_fraction AS BIGINT) + <= CAST(@remaining_budget / cdp.dynamic_price AS BIGINT) + THEN CAST(cdp.gathered * @buy_qty_fraction AS BIGINT) + ELSE CAST(@remaining_budget / cdp.dynamic_price AS BIGINT) + END AS order_qty + FROM dbo.fn_CalculateDynamicPlasmaPrices(3) cdp + JOIN dbo.entitydefaults ed ON cdp.plasma_type = ed.definitionname + CROSS JOIN Markets m + ) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT marketeid, itemdefinition, 0, 0, 0, unit_price, order_qty, 1, 1 + FROM GammaOrders + WHERE order_qty > 0; + + -- Step 2: Fetch central market EID and vendor EID + SELECT @marketeid = eid + FROM entities + WHERE ename = 'def_public_market_megacorp_TM_base_tm_pve'; + + SELECT @vendoreid = vendorEID + FROM dbo.vendors + WHERE marketEID = @marketeid; + + -- Step 3: Product auto sell orders — price at cost * product_sell_margin + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + ed.definition, + @vendoreid, + 0, + 1, + pc.production_cost_nic * @product_sell_margin, + moc.amount, + 1, + 1 + FROM market_orders_configuration moc + INNER JOIN entitydefaults ed ON moc.definitionname = ed.definitionname + INNER JOIN #prod_costs pc ON moc.definitionname = pc.product + WHERE moc.create_sell_orders = 1; + + -- Step 4: Raw material buy orders — weekly-cap sized, daily-budget guarded. + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 0, + pc.production_cost_nic, + CASE + WHEN @remaining_rawmat_budget <= 0 OR pc.production_cost_nic <= 0 THEN 0 + WHEN cr.effective_weekly_cap = 0 + -- Unlimited cap: bounded only by daily NIC budget + THEN CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + WHEN cr.effective_weekly_cap <= ISNULL(wp.qty_this_week, 0) THEN 0 + WHEN (cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0)) + <= CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + THEN cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0) + ELSE CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + END AS order_qty, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + LEFT JOIN #weekly_purchased wp ON wp.definitionname = cr.definitionname + WHERE cr.create_buy_orders = 1 + AND pc.production_cost_nic > 0 + AND @remaining_rawmat_budget > 0 + AND ( + cr.effective_weekly_cap = 0 + OR ISNULL(wp.qty_this_week, 0) < cr.effective_weekly_cap + ); + + -- Step 5: Raw material sell orders — quantity = effective_weekly_cap (0 → fallback 10 000 000). + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 1, + pc.production_cost_nic * @raw_mat_sell_multiplier, + CASE WHEN cr.effective_weekly_cap = 0 THEN 10000000 ELSE cr.effective_weekly_cap END, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + WHERE cr.create_sell_orders = 1 + AND pc.production_cost_nic > 0; + + -- Step 6: Production item buyback buy orders — price at cost * product_buyback_margin + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + ed.definition, + @vendoreid, + 0, + 0, + pc.production_cost_nic * @product_buyback_margin, + moc.amount, + 1, + 1 + FROM market_orders_configuration moc + INNER JOIN entitydefaults ed ON moc.definitionname = ed.definitionname + INNER JOIN #prod_costs pc ON moc.definitionname = pc.product + WHERE moc.create_buyback_orders = 1; + + -- 90-day rolling cleanup for weekly tracking table + DECLARE @today_cleanup DATE = CAST(GETUTCDATE() AS DATE); + DELETE FROM automarket_rawmat_weekly_tracking + WHERE week_start < DATEADD(DAY, -90, @today_cleanup); + + END TRY + BEGIN CATCH + PRINT 'Error in usp_RefreshAutoMarketOrders: ' + ERROR_MESSAGE(); + THROW; + END CATCH +END; +GO 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 cdcde77f..c0d23fdd 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_RecordRawMatWeeklyPurchased.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql new file mode 100644 index 00000000..9e16092a --- /dev/null +++ b/docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql @@ -0,0 +1,26 @@ +USE [perpetuumsa] +GO + +---- Upsert raw material AutoMarket purchase record for weekly quantity cap tracking. +---- Called by Market.FulfillSellOrderInstantly for every AutoMarket raw material buy +---- order fulfillment — alongside sp_RecordRawMatPurchased. + +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatWeeklyPurchased] + @week_start DATE, + @definitionname VARCHAR(100), + @quantity BIGINT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.automarket_rawmat_weekly_tracking WITH (HOLDLOCK) AS target + USING (SELECT @week_start, @definitionname, @quantity) + AS source(week_start, definitionname, qty_purchased) + ON target.week_start = source.week_start + AND target.definitionname = source.definitionname + WHEN MATCHED THEN + UPDATE SET qty_purchased = target.qty_purchased + source.qty_purchased + WHEN NOT MATCHED THEN + INSERT (week_start, definitionname, qty_purchased) + VALUES (source.week_start, source.definitionname, source.qty_purchased); +END; +GO 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 b1a648cc..b0cce391 100644 --- a/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql +++ b/docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql @@ -1,6 +1,6 @@ USE [perpetuumsa] GO -/****** Object: StoredProcedure [dbo].[usp_RefreshAutoMarketOrders] Script Date: 28.05.2026 ******/ +/****** Object: StoredProcedure [dbo].[usp_RefreshAutoMarketOrders] Script Date: 10.06.2026 ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON @@ -21,32 +21,6 @@ BEGIN DECLARE @marketeid BIGINT; DECLARE @vendoreid BIGINT; - -- Step 0: Snapshot unsold and unbought items - DELETE FROM [automarket_unsold_leftovers]; - DELETE FROM [automarket_unbought_resources]; - - INSERT INTO [automarket_unsold_leftovers] (itemdefinition, quantity) - SELECT itemdefinition, SUM(CAST(quantity AS BIGINT)) - FROM marketitems - WHERE isAutoOrder = 1 AND isSell = 1 - GROUP BY itemdefinition; - - -- Unbought mats: exclude plasma (3271-3274) and any item that can be manufactured - -- (production_data.product). Using market_orders_configuration here would incorrectly - -- capture buyback orders for items just removed from the trade list, causing Step 4 - -- to re-place a buy order for them as if they were raw materials. - INSERT INTO automarket_unbought_resources (itemdefinition, quantity) - SELECT mi.itemdefinition, SUM(CAST(mi.quantity AS BIGINT)) - FROM marketitems mi - INNER JOIN entitydefaults ed ON ed.definition = mi.itemdefinition - WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 - AND mi.itemdefinition NOT IN (3271, 3272, 3273, 3274) - AND NOT EXISTS ( - SELECT 1 FROM production_data pd_check - WHERE pd_check.product = ed.definitionname - ) - GROUP BY mi.itemdefinition; - -- Step 1: Remove old auto orders DELETE FROM marketitems WHERE isAutoOrder = 1; @@ -57,12 +31,42 @@ BEGIN CREATE INDEX IX_pc_product ON #prod_costs (product); - SELECT product, raw_material, total_quantity - INTO #raw_materials - FROM v_required_raw_materials; + -- Week start: Monday of current UTC week (matches recalculate_raw_material_prices formula) + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); + + DECLARE @weekly_cap_default BIGINT = ( + SELECT CAST(param_value AS BIGINT) FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default' + ); + + -- All qualifying raw materials with effective cap and buy/sell flags. + -- Materialized once; Steps 4 and 5 both read from this table. + SELECT + ed.definition, + ed.definitionname, + CASE + WHEN o.weekly_cap_override IS NOT NULL THEN CAST(o.weekly_cap_override AS BIGINT) + ELSE @weekly_cap_default + END AS effective_weekly_cap, -- 0 = unlimited + ISNULL(o.create_buy_orders, 1) AS create_buy_orders, + ISNULL(o.create_sell_orders, 1) AS create_sell_orders + INTO #covered_rawmats + FROM entitydefaults ed + LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname + WHERE ed.categoryflags IN (0x10114, 0x20114, 0x40114) -- cf_organic, cf_ore, cf_liquid + AND ed.enabled = 1 + AND ed.hidden = 0; + + CREATE INDEX IX_crm_def ON #covered_rawmats (definition); + CREATE INDEX IX_crm_name ON #covered_rawmats (definitionname); - CREATE INDEX IX_rm_product ON #raw_materials (product); - CREATE INDEX IX_rm_raw ON #raw_materials (raw_material); + -- Weekly purchases so far for the current week, per material. + SELECT definitionname, ISNULL(SUM(qty_purchased), 0) AS qty_this_week + INTO #weekly_purchased + FROM automarket_rawmat_weekly_tracking + WHERE week_start >= @week_start + GROUP BY definitionname; + + CREATE INDEX IX_wp_name ON #weekly_purchased (definitionname); -- Budget and config params DECLARE @buy_qty_fraction FLOAT = ( @@ -228,78 +232,58 @@ BEGIN 1 FROM market_orders_configuration moc INNER JOIN entitydefaults ed ON moc.definitionname = ed.definitionname - INNER JOIN #prod_costs pc ON moc.definitionname = pc.product; + INNER JOIN #prod_costs pc ON moc.definitionname = pc.product + WHERE moc.create_sell_orders = 1; - -- Step 4: Raw material buy orders — skip all if daily budget exhausted - ;WITH NeedProducts AS ( - SELECT - moc.definitionname AS product, - CAST(moc.amount - ISNULL(us.quantity, 0) AS BIGINT) AS need_amount - FROM market_orders_configuration moc - INNER JOIN entitydefaults ed ON moc.definitionname = ed.definitionname - LEFT JOIN automarket_unsold_leftovers us ON ed.definition = us.itemdefinition - ), - RequiredRaw AS ( - SELECT - ed.definition AS raw_material_def, - SUM(rm.total_quantity * np.need_amount) AS required_from_products - FROM NeedProducts np - INNER JOIN #raw_materials rm ON rm.product = np.product - INNER JOIN entitydefaults ed ON ed.definitionname = rm.raw_material - WHERE np.need_amount > 0 - GROUP BY ed.definition - ), - Unbought AS ( - SELECT - ub.itemdefinition AS raw_material_def, - SUM(ub.quantity) AS required_from_unbought - FROM automarket_unbought_resources ub - GROUP BY ub.itemdefinition - ), - Combined AS ( - SELECT - COALESCE(r.raw_material_def, u.raw_material_def) AS combined_def, - COALESCE(r.required_from_products, 0) + COALESCE(u.required_from_unbought, 0) AS total_required_quantity - FROM RequiredRaw r - FULL OUTER JOIN Unbought u ON u.raw_material_def = r.raw_material_def - ) + -- Step 4: Raw material buy orders — weekly-cap sized, daily-budget guarded. INSERT INTO marketitems ( marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder ) SELECT @marketeid, - c.combined_def, + cr.definition, @vendoreid, - 0, - 0, - apc.production_cost_nic, - c.total_required_quantity, - 1, - 1 - FROM Combined c - INNER JOIN entitydefaults ed ON ed.definition = c.combined_def - INNER JOIN #prod_costs apc ON ed.definitionname = apc.product - WHERE c.total_required_quantity > 0 - AND @remaining_rawmat_budget > 0; + 0, 0, + pc.production_cost_nic, + CASE + WHEN @remaining_rawmat_budget <= 0 OR pc.production_cost_nic <= 0 THEN 0 + WHEN cr.effective_weekly_cap = 0 + -- Unlimited cap: bounded only by daily NIC budget + THEN CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + WHEN cr.effective_weekly_cap <= ISNULL(wp.qty_this_week, 0) THEN 0 + WHEN (cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0)) + <= CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + THEN cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0) + ELSE CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + END AS order_qty, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + LEFT JOIN #weekly_purchased wp ON wp.definitionname = cr.definitionname + WHERE cr.create_buy_orders = 1 + AND pc.production_cost_nic > 0 + AND @remaining_rawmat_budget > 0 + AND ( + cr.effective_weekly_cap = 0 + OR ISNULL(wp.qty_this_week, 0) < cr.effective_weekly_cap + ); - -- Step 5: Raw resource sell orders — price at cost * raw_mat_sell_multiplier + -- Step 5: Raw material sell orders — quantity = effective_weekly_cap (0 → fallback 10 000 000). INSERT INTO marketitems ( marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder ) SELECT @marketeid, - ed.definition, + cr.definition, @vendoreid, - 0, - 1, - apc.production_cost_nic * @raw_mat_sell_multiplier, - 10000000, - 1, - 1 - FROM #raw_materials rrm - INNER JOIN entitydefaults ed ON rrm.raw_material = ed.definitionname - INNER JOIN #prod_costs apc ON rrm.raw_material = apc.product - GROUP BY ed.definition, apc.production_cost_nic; + 0, 1, + pc.production_cost_nic * @raw_mat_sell_multiplier, + CASE WHEN cr.effective_weekly_cap = 0 THEN 10000000 ELSE cr.effective_weekly_cap END, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + WHERE cr.create_sell_orders = 1 + AND pc.production_cost_nic > 0; -- Step 6: Production item buyback buy orders — price at cost * product_buyback_margin INSERT INTO marketitems ( @@ -317,7 +301,13 @@ BEGIN 1 FROM market_orders_configuration moc INNER JOIN entitydefaults ed ON moc.definitionname = ed.definitionname - INNER JOIN #prod_costs pc ON moc.definitionname = pc.product; + INNER JOIN #prod_costs pc ON moc.definitionname = pc.product + WHERE moc.create_buyback_orders = 1; + + -- 90-day rolling cleanup for weekly tracking table + DECLARE @today_cleanup DATE = CAST(GETUTCDATE() AS DATE); + DELETE FROM automarket_rawmat_weekly_tracking + WHERE week_start < DATEADD(DAY, -90, @today_cleanup); END TRY BEGIN CATCH diff --git a/docs/db_structure/views/v_all_production_costs.sql b/docs/db_structure/views/v_all_production_costs.sql index 2e3bab65..8a1bba4d 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: 28.05.2026 ******/ +/****** Object: View [dbo].[v_all_production_costs] Script Date: 10.06.2026 ******/ SET ANSI_NULLS ON GO @@ -82,11 +82,17 @@ computed_costs AS ( ), raw_resources AS ( SELECT - base.raw_material AS product, + base.definitionname AS product, ISNULL(mp.unit_price, msp.price) AS production_cost_nic - FROM (SELECT DISTINCT raw_material FROM v_required_raw_materials) base + FROM ( + SELECT definitionname + FROM dbo.entitydefaults + WHERE categoryflags IN (0x10114, 0x20114, 0x40114) -- cf_organic, cf_ore, cf_liquid + AND enabled = 1 + AND hidden = 0 + ) base LEFT JOIN latest_market_prices mp - ON base.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + ON base.definitionname COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT CROSS JOIN max_scarcity_price msp ), final_costs AS ( diff --git a/docs/db_structure/views/v_required_raw_materials.sql b/docs/db_structure/views/v_trade_list_raw_material_demand.sql similarity index 81% rename from docs/db_structure/views/v_required_raw_materials.sql rename to docs/db_structure/views/v_trade_list_raw_material_demand.sql index d4a9a5ae..e806e378 100644 --- a/docs/db_structure/views/v_required_raw_materials.sql +++ b/docs/db_structure/views/v_trade_list_raw_material_demand.sql @@ -1,4 +1,4 @@ -/****** Object: View [dbo].[v_required_raw_materials] Script Date: 10.05.2026 7:26:34 ******/ +/****** Object: View [dbo].[v_trade_list_raw_material_demand] Script Date: 10.06.2026 7:26:34 ******/ SET ANSI_NULLS ON GO @@ -6,10 +6,13 @@ SET QUOTED_IDENTIFIER ON GO +-- Returns the raw materials (and quantities) required to fulfil the AutoMarket trade list. +-- Used exclusively as a demand signal in recalculate_raw_material_prices. +-- Material ENUMERATION is now driven by entitydefaults (cf_raw_material flag) — not this view. -- prod_data inlines production_data to avoid view-nesting-level accumulation inside the recursive member. -- SQL Server increments the view nesting counter on every recursive iteration that references an external -- view; a CTE reference does not count, so chains deeper than ~28 levels no longer hit the 32-level limit. -CREATE OR ALTER VIEW [dbo].[v_required_raw_materials] AS +CREATE OR ALTER VIEW [dbo].[v_trade_list_raw_material_demand] AS WITH prod_data AS ( SELECT ed.definitionname AS product, diff --git a/docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md b/docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md new file mode 100644 index 00000000..c421ca82 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md @@ -0,0 +1,1614 @@ +# IMPROVEMENT-040: AutoMarket Raw Material Decoupling — 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:** Decouple raw material coverage from the trade list by enumerating qualifying materials from `entitydefaults` (via the `cf_raw_material` category flag), replace the BOM-explosion-based buy/sell order sizing with a configurable weekly quantity cap per material, and expose both guardrails in the AdminTool. + +**Architecture:** A new `#covered_rawmats` temp table (derived from `entitydefaults` + `automarket_rawmat_overrides`) replaces `#raw_materials` as the raw material source in `usp_RefreshAutoMarketOrders`. The renamed view `v_trade_list_raw_material_demand` (formerly `v_required_raw_materials`) is retained only for the demand signal in `recalculate_raw_material_prices`. A new `automarket_rawmat_weekly_tracking` table records fulfilled raw material purchases per week; the market sell handler writes to it alongside the existing `rawmat_purchased` write. + +**Tech Stack:** SQL Server (T-SQL), .NET 8, C# 12, WPF/XAML, CommunityToolkit.Mvvm, Microsoft.Data.SqlClient + +**Spec:** `docs/superpowers/specs/2026-06-10-automarket-raw-material-decoupling-design.md` + +--- + +## File Map + +**Create:** +- `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql` +- `docs/db_structure/views/v_trade_list_raw_material_demand.sql` +- `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs` +- `src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs` +- `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml` +- `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml.cs` + +**Modify:** +- `docs/db_structure/views/v_required_raw_materials.sql` *(delete — replaced by v_trade_list_raw_material_demand.sql)* +- `docs/db_structure/views/v_all_production_costs.sql` +- `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` +- `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` +- `docs/db_structure/database_schema_documentation.md` +- `src/Perpetuum/Services/MarketEngine/Market.cs` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` +- `src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs` +- `src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs` +- `src/Perpetuum.AdminTool/Views/AutoMarketView.xaml` + +--- + +## Task 1: DB Migration SQL + `sp_RecordRawMatWeeklyPurchased` stored procedure + +**Files:** +- Create: `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql` +- Create: `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql` +- Modify: `docs/db_structure/database_schema_documentation.md` + +- [ ] **Step 1: Create the migration file** + +```sql +-- docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql +-- IMPROVEMENT-040: AutoMarket Raw Material Decoupling +-- Run against perpetuumsa while server is ONLINE (no data migration needed). +-- Apply in order — objects are dependencies of later steps. + +USE [perpetuumsa]; +GO + +-------------------------------------------------------------------- +-- 1. New table: per-material AutoMarket overrides +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.automarket_rawmat_overrides', 'U') IS NULL +BEGIN + CREATE TABLE dbo.automarket_rawmat_overrides ( + definitionname VARCHAR(100) NOT NULL, + weekly_cap_override INT NULL, -- NULL = use global default; 0 = unlimited + create_buy_orders BIT NOT NULL DEFAULT 1, + create_sell_orders BIT NOT NULL DEFAULT 1, + CONSTRAINT PK_rawmat_overrides PRIMARY KEY CLUSTERED (definitionname) + ); + PRINT 'Created automarket_rawmat_overrides'; +END +GO + +-------------------------------------------------------------------- +-- 2. New table: weekly quantity tracking per raw material +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.automarket_rawmat_weekly_tracking', 'U') IS NULL +BEGIN + CREATE TABLE dbo.automarket_rawmat_weekly_tracking ( + week_start DATE NOT NULL, + definitionname VARCHAR(100) NOT NULL, + qty_purchased BIGINT NOT NULL DEFAULT 0, + CONSTRAINT PK_rawmat_weekly PRIMARY KEY CLUSTERED (week_start, definitionname) + ); + PRINT 'Created automarket_rawmat_weekly_tracking'; +END +GO + +-------------------------------------------------------------------- +-- 3. New automarket_config row: weekly_rawmat_cap_default +-------------------------------------------------------------------- +IF NOT EXISTS (SELECT 1 FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default') +BEGIN + INSERT INTO automarket_config (param_name, param_value) + VALUES ('weekly_rawmat_cap_default', 500000000); + PRINT 'Inserted weekly_rawmat_cap_default into automarket_config'; +END +GO + +-------------------------------------------------------------------- +-- 4. Index on resource_market_prices(calculated_on, resource_name) +-- Required for efficient MERGE in recalculate_raw_material_prices +-- after material list expands. +-------------------------------------------------------------------- +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.resource_market_prices') + AND name = 'IX_rmp_on_name' +) +BEGIN + CREATE NONCLUSTERED INDEX IX_rmp_on_name + ON dbo.resource_market_prices (calculated_on, resource_name); + PRINT 'Created IX_rmp_on_name on resource_market_prices'; +END +GO + +-------------------------------------------------------------------- +-- 5. Rename view: v_required_raw_materials → v_trade_list_raw_material_demand +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.v_required_raw_materials', 'V') IS NOT NULL + AND OBJECT_ID('dbo.v_trade_list_raw_material_demand', 'V') IS NULL +BEGIN + EXEC sp_rename 'dbo.v_required_raw_materials', 'v_trade_list_raw_material_demand'; + PRINT 'Renamed v_required_raw_materials to v_trade_list_raw_material_demand'; +END +GO + +-------------------------------------------------------------------- +-- 6. New stored procedure: sp_RecordRawMatWeeklyPurchased +-------------------------------------------------------------------- +-- (See step 2 for the CREATE OR ALTER body — apply after running this file) +``` + +- [ ] **Step 2: Create `sp_RecordRawMatWeeklyPurchased` doc file** + +```sql +-- docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql +USE [perpetuumsa] +GO + +---- Upsert raw material AutoMarket purchase record for weekly quantity cap tracking. +---- Called by Market.FulfillSellOrderInstantly for every AutoMarket raw material buy +---- order fulfillment — alongside sp_RecordRawMatPurchased. + +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatWeeklyPurchased] + @week_start DATE, + @definitionname VARCHAR(100), + @quantity BIGINT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.automarket_rawmat_weekly_tracking AS target + USING (SELECT @week_start, @definitionname, @quantity) + AS source(week_start, definitionname, qty_purchased) + ON target.week_start = source.week_start + AND target.definitionname = source.definitionname + WHEN MATCHED THEN + UPDATE SET qty_purchased = target.qty_purchased + source.qty_purchased + WHEN NOT MATCHED THEN + INSERT (week_start, definitionname, qty_purchased) + VALUES (source.week_start, source.definitionname, source.qty_purchased); +END; +GO +``` + +Add this SP to the migration file above step 6 comment block — paste the `CREATE OR ALTER PROCEDURE` body there, or execute it separately after the migration. + +- [ ] **Step 3: Add this SP to the end of the migration file** + +Append to `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql`: + +```sql +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatWeeklyPurchased] + @week_start DATE, + @definitionname VARCHAR(100), + @quantity BIGINT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.automarket_rawmat_weekly_tracking AS target + USING (SELECT @week_start, @definitionname, @quantity) + AS source(week_start, definitionname, qty_purchased) + ON target.week_start = source.week_start + AND target.definitionname = source.definitionname + WHEN MATCHED THEN + UPDATE SET qty_purchased = target.qty_purchased + source.qty_purchased + WHEN NOT MATCHED THEN + INSERT (week_start, definitionname, qty_purchased) + VALUES (source.week_start, source.definitionname, source.qty_purchased); +END; +GO +``` + +- [ ] **Step 4: Update `database_schema_documentation.md`** + +In `docs/db_structure/database_schema_documentation.md`: + +1. In the table of contents, add entries (alphabetical order near `automarket_config`): +```markdown +- [automarket_rawmat_overrides](#automarket-rawmat-overrides) +- [automarket_rawmat_weekly_tracking](#automarket-rawmat-weekly-tracking) +``` + +2. After the `automarket_config` section, add: +```markdown +## automarket_rawmat_overrides + +**Schema:** `dbo` + +Per-material overrides for AutoMarket raw material coverage. Materials with no row use global defaults from `automarket_config`. + +### Columns + +| Column | Definition | +|---|---| +| `definitionname` | `varchar(100) [not null, pk]` | +| `weekly_cap_override` | `int [null]` — NULL = use global default; 0 = unlimited | +| `create_buy_orders` | `bit [not null, default: 1]` | +| `create_sell_orders` | `bit [not null, default: 1]` | + +--- + +## automarket_rawmat_weekly_tracking + +**Schema:** `dbo` + +Tracks units of each raw material purchased via AutoMarket buy orders per week. Written by `sp_RecordRawMatWeeklyPurchased`. Rolled up by `usp_RefreshAutoMarketOrders` to enforce the per-material weekly cap. Cleaned up at 90-day rolling window. + +### Columns + +| Column | Definition | +|---|---| +| `week_start` | `date [not null, pk]` — Monday of the ISO week | +| `definitionname` | `varchar(100) [not null, pk]` | +| `qty_purchased` | `bigint [not null, default: 0]` | +``` + +3. In the `automarket_config` seeded rows table, add the new row: +```markdown +| `weekly_rawmat_cap_default` | `500000000` | Default weekly buy quantity cap per raw material. 0 = unlimited. | +``` + +4. Update the `daily_rawmat_budget_nic` description to add `(0 = unlimited)`: +```markdown +| `daily_rawmat_budget_nic` | `5000000` | Max NIC spent on raw material buy orders per UTC calendar day (0 = unlimited). | +``` + +5. Find the `v_required_raw_materials` reference in any index/TOC and rename it to `v_trade_list_raw_material_demand`. + +- [ ] **Step 5: Commit** + +``` +git add docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql +git add docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql +git add docs/db_structure/database_schema_documentation.md +git commit -m "IMPROVEMENT-040: migration SQL, sp_RecordRawMatWeeklyPurchased, schema docs" +``` + +--- + +## Task 2: View rename doc + `v_all_production_costs` update + +**Files:** +- Create: `docs/db_structure/views/v_trade_list_raw_material_demand.sql` +- Delete: `docs/db_structure/views/v_required_raw_materials.sql` +- Modify: `docs/db_structure/views/v_all_production_costs.sql` + +- [ ] **Step 1: Create renamed view doc** + +Create `docs/db_structure/views/v_trade_list_raw_material_demand.sql` with contents identical to the current `docs/db_structure/views/v_required_raw_materials.sql`, but update the object name in the header comment and `CREATE OR ALTER VIEW` statement: + +```sql +/****** Object: View [dbo].[v_trade_list_raw_material_demand] Script Date: 10.06.2026 ******/ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +-- Returns the raw materials (and quantities) required to fulfil the AutoMarket trade list. +-- Used exclusively as a demand signal in recalculate_raw_material_prices. +-- Material ENUMERATION is now driven by entitydefaults (cf_raw_material flag) — not this view. +-- prod_data inlines production_data to avoid view-nesting-level accumulation inside the recursive member. +CREATE OR ALTER VIEW [dbo].[v_trade_list_raw_material_demand] AS + WITH prod_data AS ( + SELECT + ed.definitionname AS product, + ced.definitionname AS components, + c.componentamount AS amount + FROM dbo.components c + INNER JOIN dbo.entitydefaults ed ON c.definition = ed.definition + INNER JOIN dbo.entitydefaults ced ON c.componentdefinition = ced.definition + WHERE ed.purchasable = 1 AND ed.enabled = 1 AND ed.hidden = 0 + ), + RecursiveBreakdown AS ( + -- Base case: direct components + SELECT + moc.definitionname AS product, + pd.components AS component, + SUM(CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT)) AS total_amount + FROM dbo.market_orders_configuration moc + JOIN prod_data pd ON moc.definitionname = pd.product + GROUP BY moc.definitionname, pd.components + + UNION ALL + + -- Recursive case: break down intermediate components + SELECT + rb.product, + pd.components AS component, + rb.total_amount * CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT) AS total_amount + FROM RecursiveBreakdown rb + JOIN prod_data pd ON rb.component = pd.product + ) + + -- Final aggregation: only raw materials (not further craftable) + SELECT + rb.product as product, + rb.component AS raw_material, + SUM(rb.total_amount) AS total_quantity + FROM RecursiveBreakdown rb + LEFT JOIN prod_data pd ON rb.component = pd.product + WHERE pd.product IS NULL + GROUP BY rb.product, rb.component; +GO +``` + +- [ ] **Step 2: Delete the old view doc** + +``` +git rm docs/db_structure/views/v_required_raw_materials.sql +``` + +- [ ] **Step 3: Update `v_all_production_costs` — `raw_resources` CTE** + +In `docs/db_structure/views/v_all_production_costs.sql`, replace the `raw_resources` CTE: + +Old: +```sql +raw_resources AS ( + SELECT + base.raw_material AS product, + ISNULL(mp.unit_price, msp.price) AS production_cost_nic + FROM (SELECT DISTINCT raw_material FROM v_required_raw_materials) base + LEFT JOIN latest_market_prices mp + ON base.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp +), +``` + +New: +```sql +raw_resources AS ( + SELECT + base.definitionname AS product, + ISNULL(mp.unit_price, msp.price) AS production_cost_nic + FROM ( + SELECT definitionname + FROM dbo.entitydefaults + WHERE (categoryflags & 276) = 276 -- cf_raw_material bitmask + AND enabled = 1 + AND hidden = 0 + ) base + LEFT JOIN latest_market_prices mp + ON base.definitionname COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp +), +``` + +Also update the script date in the header comment to `10.06.2026`. + +- [ ] **Step 4: Apply view changes to the database** + +Run in SQL Server Management Studio (or equivalent) against perpetuumsa: + +```sql +-- Apply renamed view +EXEC sp_rename 'dbo.v_required_raw_materials', 'v_trade_list_raw_material_demand'; +-- (skip if already applied in Task 1 migration) + +-- Apply updated v_all_production_costs (copy full CREATE OR ALTER VIEW from the updated doc file) +``` + +Expected: no errors. Run `SELECT TOP 5 * FROM v_trade_list_raw_material_demand` and `SELECT TOP 5 * FROM v_all_production_costs` to verify both return rows. + +- [ ] **Step 5: Commit** + +``` +git add docs/db_structure/views/v_trade_list_raw_material_demand.sql +git add docs/db_structure/views/v_all_production_costs.sql +git commit -m "IMPROVEMENT-040: rename v_required_raw_materials doc, update v_all_production_costs raw_resources CTE" +``` + +--- + +## Task 3: Update `recalculate_raw_material_prices` + +**Files:** +- Modify: `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` + +- [ ] **Step 1: Replace the `materials` CTE and update `demand_cte` view reference** + +In `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql`, replace the `materials` and `demand_cte` CTEs inside the `WITH` block: + +Old: +```sql + demand_cte AS ( + SELECT raw_material, SUM(total_quantity) / 7.0 AS daily_demand + FROM v_required_raw_materials + GROUP BY raw_material + ) , + materials AS ( + SELECT DISTINCT raw_material AS resource_name + FROM v_required_raw_materials + ) , +``` + +New: +```sql + demand_cte AS ( + SELECT raw_material, SUM(total_quantity) / 7.0 AS daily_demand + FROM v_trade_list_raw_material_demand + GROUP BY raw_material + ) , + -- Material enumeration is now driven by entitydefaults (cf_raw_material = 0x114). + -- New materials with no demand data default to ds_max scarcity price (ISNULL branch below). + materials AS ( + SELECT definitionname AS resource_name + FROM dbo.entitydefaults + WHERE (categoryflags & 276) = 276 + AND enabled = 1 + AND hidden = 0 + ) , +``` + +Also update the script date in the header comment to `10.06.2026`. + +- [ ] **Step 2: Apply to the database** + +Run in SSMS: + +```sql +-- Copy full CREATE OR ALTER PROCEDURE body from updated doc file and execute. +``` + +Expected: procedure compiles without error. Run: +```sql +EXEC recalculate_raw_material_prices; +SELECT COUNT(*) FROM resource_market_prices +WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices); +``` + +Expected: row count increases compared to before (now includes all cf_raw_material items, not just trade-list BOM materials). + +- [ ] **Step 3: Commit** + +``` +git add docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql +git commit -m "IMPROVEMENT-040: recalculate_raw_material_prices uses entitydefaults for material enumeration" +``` + +--- + +## Task 4: Rework `usp_RefreshAutoMarketOrders` + +**Files:** +- Modify: `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` + +- [ ] **Step 1: Remove Step 0 snapshot blocks** + +In `usp_RefreshAutoMarketOrders`, delete these blocks entirely (Steps 4 and 5 are now cap-driven, not need-driven): + +```sql + -- Step 0: Snapshot unsold and unbought items + DELETE FROM [automarket_unsold_leftovers]; + DELETE FROM [automarket_unbought_resources]; + + INSERT INTO [automarket_unsold_leftovers] (itemdefinition, quantity) + SELECT itemdefinition, SUM(CAST(quantity AS BIGINT)) + FROM marketitems + WHERE isAutoOrder = 1 AND isSell = 1 + GROUP BY itemdefinition; + + -- Unbought mats: ... + INSERT INTO automarket_unbought_resources (itemdefinition, quantity) + ... + GROUP BY mi.itemdefinition; +``` + +Keep the Step 1 block (`DELETE FROM marketitems WHERE isAutoOrder = 1;`) and everything that follows. + +- [ ] **Step 2: Remove `#raw_materials` temp table, add `#covered_rawmats` and `@week_start`** + +After the `#prod_costs` block (which ends with `CREATE INDEX IX_pc_product ON #prod_costs (product);`), replace: + +```sql + SELECT product, raw_material, total_quantity + INTO #raw_materials + FROM v_required_raw_materials; + + CREATE INDEX IX_rm_product ON #raw_materials (product); + CREATE INDEX IX_rm_raw ON #raw_materials (raw_material); +``` + +With: + +```sql + -- Week start: Monday of current UTC week (matches recalculate_raw_material_prices formula) + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); + + DECLARE @weekly_cap_default BIGINT = ( + SELECT CAST(param_value AS BIGINT) FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default' + ); + + -- All qualifying raw materials with effective cap and buy/sell flags. + -- Materialized once; Steps 4 and 5 both read from this table. + SELECT + ed.definition, + ed.definitionname, + CASE + WHEN o.weekly_cap_override IS NOT NULL THEN CAST(o.weekly_cap_override AS BIGINT) + ELSE @weekly_cap_default + END AS effective_weekly_cap, -- 0 = unlimited + ISNULL(o.create_buy_orders, 1) AS create_buy_orders, + ISNULL(o.create_sell_orders, 1) AS create_sell_orders + INTO #covered_rawmats + FROM entitydefaults ed + LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname + WHERE (ed.categoryflags & 276) = 276 + AND ed.enabled = 1 + AND ed.hidden = 0; + + CREATE INDEX IX_crm_def ON #covered_rawmats (definition); + CREATE INDEX IX_crm_name ON #covered_rawmats (definitionname); + + -- Weekly purchases so far for the current week, per material. + SELECT definitionname, ISNULL(SUM(qty_purchased), 0) AS qty_this_week + INTO #weekly_purchased + FROM automarket_rawmat_weekly_tracking + WHERE week_start >= @week_start + GROUP BY definitionname; + + CREATE INDEX IX_wp_name ON #weekly_purchased (definitionname); +``` + +- [ ] **Step 3: Replace Step 4 (raw material buy orders)** + +Replace the entire Step 4 block: + +```sql + -- Old Step 4: ...NeedProducts...RequiredRaw...Unbought...Combined... + -- (all of it, from the ';WITH NeedProducts' comment through the final WHERE clause) +``` + +With: + +```sql + -- Step 4: Raw material buy orders — weekly-cap sized, daily-budget guarded. + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 0, + pc.production_cost_nic, + CASE + WHEN @remaining_rawmat_budget <= 0 OR pc.production_cost_nic <= 0 THEN 0 + WHEN cr.effective_weekly_cap = 0 + -- Unlimited cap: bounded only by daily NIC budget + THEN CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + WHEN cr.effective_weekly_cap <= ISNULL(wp.qty_this_week, 0) THEN 0 + WHEN (cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0)) + <= CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + THEN cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0) + ELSE CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + END AS order_qty, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + LEFT JOIN #weekly_purchased wp ON wp.definitionname = cr.definitionname + WHERE cr.create_buy_orders = 1 + AND pc.production_cost_nic > 0 + AND @remaining_rawmat_budget > 0 + AND ( + cr.effective_weekly_cap = 0 + OR ISNULL(wp.qty_this_week, 0) < cr.effective_weekly_cap + ); +``` + +- [ ] **Step 4: Replace Step 5 (raw material sell orders)** + +Replace the entire Step 5 block: + +```sql + -- Old Step 5: + -- INSERT INTO marketitems (...) + -- SELECT @marketeid, ed.definition, @vendoreid, 0, 1, apc.production_cost_nic * @raw_mat_sell_multiplier, + -- 10000000, 1, 1 + -- FROM #raw_materials rrm + -- INNER JOIN entitydefaults ed ON rrm.raw_material = ed.definitionname + -- INNER JOIN #prod_costs apc ON rrm.raw_material = apc.product + -- GROUP BY ed.definition, apc.production_cost_nic; +``` + +With: + +```sql + -- Step 5: Raw material sell orders — quantity = effective_weekly_cap (0 → fallback 10 000 000). + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 1, + pc.production_cost_nic * @raw_mat_sell_multiplier, + CASE WHEN cr.effective_weekly_cap = 0 THEN 10000000 ELSE cr.effective_weekly_cap END, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + WHERE cr.create_sell_orders = 1 + AND pc.production_cost_nic > 0; +``` + +- [ ] **Step 5: Add weekly tracking cleanup to the cleanup block** + +In `recalculate_raw_material_prices`, the cleanup block already exists. In `usp_RefreshAutoMarketOrders` there is no cleanup block — add it at the end of the procedure, inside the `END TRY` block, before `END TRY`: + +```sql + -- 90-day rolling cleanup for weekly tracking table + DECLARE @today_cleanup DATE = CAST(GETUTCDATE() AS DATE); + DELETE FROM automarket_rawmat_weekly_tracking + WHERE week_start < DATEADD(DAY, -90, @today_cleanup); +``` + +- [ ] **Step 6: Apply to the database and verify** + +Run the full updated `CREATE OR ALTER PROCEDURE [dbo].[usp_RefreshAutoMarketOrders]` from the doc file in SSMS. + +Then run: +```sql +EXEC usp_RefreshAutoMarketOrders; + +-- Verify raw material orders now cover materials outside the trade list BOM: +SELECT COUNT(*) AS raw_buy_orders +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND (ed.categoryflags & 276) = 276; + +-- Should be larger than before (all cf_raw_material items, not just trade-listed BOM materials). +``` + +- [ ] **Step 7: Commit** + +``` +git add docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql +git commit -m "IMPROVEMENT-040: usp_RefreshAutoMarketOrders — #covered_rawmats replaces #raw_materials, Steps 4+5 reworked" +``` + +--- + +## Task 5: C# weekly tracking write in `Market.cs` + +**Files:** +- Modify: `src/Perpetuum/Services/MarketEngine/Market.cs` + +The three existing `sp_RecordRawMatPurchased` call sites are in `FulfillSellOrderInstantly`. Each is guarded by: +```csharp +if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) +``` + +Add a private helper method and call it alongside each `sp_RecordRawMatPurchased` block. + +- [ ] **Step 1: Add `GetWeekStart` and `RecordWeeklyRawMatPurchase` helpers** + +Find the end of the `Market` class (before the closing `}`). Add: + +```csharp +private static DateTime GetWeekStart(DateTime utcNow) +{ + // Matches SQL formula: DATEADD(DAY, -DATEPART(WEEKDAY, @today) + 2, @today) + // with SQL DATEFIRST=7 (Sunday=1, Monday=2, ..., Saturday=7) + var sqlWeekday = (int)utcNow.DayOfWeek + 1; // Sunday→1, Monday→2, ..., Saturday→7 + return utcNow.Date.AddDays(-sqlWeekday + 2); +} + +private void RecordWeeklyRawMatPurchase(string definitionName, int quantity) +{ + using var scope = Db.CreateTransaction(); + Db.Query() + .CommandText("exec sp_RecordRawMatWeeklyPurchased @week_start, @definitionname, @quantity") + .SetParameter("@week_start", GetWeekStart(DateTime.UtcNow)) + .SetParameter("@definitionname", definitionName) + .SetParameter("@quantity", quantity) + .ExecuteNonQuery(); + scope.Complete(); +} +``` + +- [ ] **Step 2: Add call at first `sp_RecordRawMatPurchased` site (case: `buyOrder.quantity < itemToSell.Quantity`)** + +Find the block at approximately line 792–806: +```csharp + // Log raw material AutoMarket purchase for daily budget tracking + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordRawMatPurchased @purchased_on, @item_def, @quantity, @income") + .SetParameter("@purchased_on", DateTime.UtcNow) + .SetParameter("@item_def", itemToSell.Definition) + .SetParameter("@quantity", buyOrder.quantity) + .SetParameter("@income", buyOrder.price * buyOrder.quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } +``` + +Add immediately after the closing `}` of that `if` block: +```csharp + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + RecordWeeklyRawMatPurchase(itemToSell.ED.Name, buyOrder.quantity); +``` + +- [ ] **Step 3: Add call at second `sp_RecordRawMatPurchased` site (case: `buyOrder.quantity == itemToSell.Quantity`)** + +Find the block at approximately line 832–847 (the second occurrence of the same guard pattern). Add immediately after its closing `}`: +```csharp + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + RecordWeeklyRawMatPurchase(itemToSell.ED.Name, quantity); +``` + +Note: `quantity` here is the local variable set to `buyOrder.quantity` for this branch. + +- [ ] **Step 4: Add call at third `sp_RecordRawMatPurchased` site (infinite vendor buy order path)** + +Find the block at approximately line 884–898 (the third occurrence). Add immediately after its closing `}`: +```csharp + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + RecordWeeklyRawMatPurchase(itemToSell.ED.Name, itemToSell.Quantity); +``` + +- [ ] **Step 5: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors, 0 warnings related to changed files. + +- [ ] **Step 6: Commit** + +``` +git add src/Perpetuum/Services/MarketEngine/Market.cs +git commit -m "IMPROVEMENT-040: record weekly raw mat purchase in Market.FulfillSellOrderInstantly" +``` + +--- + +## Task 6: AdminTool row models + labels + +**Files:** +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs` +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs` + +- [ ] **Step 1: Update `AutoMarketLabels.cs`** + +Replace the file content: + +```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, 0=∞)", "Max NIC spent on raw material buy orders per calendar day. 0 = unlimited."), + ["weekly_rawmat_cap_default"] = new("Weekly Rawmat Cap (default, 0=∞)","Default max units AutoMarket buys per raw material per week. 0 = unlimited."), + ["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 2: Add `BoughtThisWeek` and `EffectiveCap` to `AutoMarketPricingTraceRow`** + +Replace the file content: + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketPricingTraceRow + { + public string ResourceName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + 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; } + public long BoughtThisWeek { get; init; } + public long EffectiveCap { get; init; } + } +} +``` + +- [ ] **Step 3: Create `AutoMarketCoveredMaterialRow.cs`** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketCoveredMaterialRow + { + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public double CurrentPrice { get; init; } + public long EffectiveCap { get; init; } // BIGINT: COALESCE(override, global default) + public int? WeeklyCapOverride { get; set; } // INT NULL: matches DB column type + public long BoughtThisWeek { get; init; } + public bool CreateBuyOrders { get; set; } + public bool CreateSellOrders { get; set; } + + // Originals for change detection — need set because QueueSave updates them after dispatch + public int? OriginalCapOverride { get; set; } + public bool OriginalBuyOrders { get; set; } + public bool OriginalSellOrders { get; set; } + + public bool HasOverride => + WeeklyCapOverride.HasValue || !CreateBuyOrders || !CreateSellOrders; + + public bool IsAtDefaults => + !WeeklyCapOverride.HasValue && CreateBuyOrders && CreateSellOrders; + } +} +``` + +- [ ] **Step 4: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs +git commit -m "IMPROVEMENT-040: labels update, PricingTraceRow new columns, AutoMarketCoveredMaterialRow" +``` + +--- + +## Task 7: `AutoMarketRepository` updates + +**Files:** +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` + +Three methods need changes; one new method is added. + +- [ ] **Step 1: Update `LoadDerivedMaterialsAsync` — rename view reference** + +In `LoadDerivedMaterialsAsync`, replace `v_required_raw_materials` with `v_trade_list_raw_material_demand`: + +```csharp + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) " + + "FROM v_trade_list_raw_material_demand " + + "GROUP BY raw_material " + + "ORDER BY raw_material"; +``` + +- [ ] **Step 2: Update `LoadPricingTraceAsync` — materials list + demand view + new columns** + +Replace the two references to `v_required_raw_materials` and add two new queries for weekly tracking and effective cap. The full updated `LoadPricingTraceAsync` method: + +```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 : (double)r.GetDecimal(0); + } + + // 2. Config params + double anchorFraction = 0.15, dsMin = 0.25, dsMax = 4.0; + long weeklyCapDefault = 500_000_000; + 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','weekly_rawmat_cap_default')"; + 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; + case "weekly_rawmat_cap_default": weeklyCapDefault = (long)r.GetDouble(1); break; + } + } + } + + var plasmaAnchor = alphaPlasmaPrice * anchorFraction; + + // 3. Supply data (last 7 days) + 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)] = ((double)r.GetDecimal(3), r.GetInt64(1), r.GetInt64(2)); + } + + // 4. Demand data (from trade-list BOM — demand signal only) + var demand = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) / 7.0 " + + "FROM v_trade_list_raw_material_demand GROUP BY raw_material"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) demand[r.GetString(0)] = (double)r.GetDecimal(1); + } + + // 5. Materials list — all cf_raw_material items (categoryflags & 276 = 276) + var materials = new List(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT definitionname FROM entitydefaults " + + "WHERE (categoryflags & 276) = 276 AND enabled = 1 AND hidden = 0 " + + "ORDER BY definitionname"; + 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); + } + + // 7. Weekly purchases this week per material + var weeklyPurchased = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "DECLARE @ws DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); " + + "SELECT definitionname, ISNULL(SUM(qty_purchased),0) " + + "FROM automarket_rawmat_weekly_tracking WHERE week_start >= @ws " + + "GROUP BY definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) weeklyPurchased[r.GetString(0)] = r.GetInt64(1); + } + + // 8. Per-material cap overrides + var capOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT definitionname, weekly_cap_override FROM automarket_rawmat_overrides WHERE weekly_cap_override IS NOT NULL"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) capOverrides[r.GetString(0)] = r.GetInt32(1); + } + + // Compute rows + var result = new List(); + foreach (var name in materials) + { + var hasSupply = supply.TryGetValue(name, out var sup); + double supplyDailyAvg = hasSupply ? sup.DailyAvg : 0; + demand.TryGetValue(name, out var dailyDemand); + + double sdRatio = supplyDailyAvg <= 0 + ? dsMax + : Math.Clamp(dailyDemand / supplyDailyAvg, dsMin, dsMax); + + double pvpFraction = (hasSupply && sup.TotalQty > 0) + ? (double)sup.PvpQty / sup.TotalQty + : 1.0; + + var riskMultiplier = 1.0 + pvpFraction; + var computedPrice = Math.Round(plasmaAnchor * sdRatio * riskMultiplier, 2); + var effectiveCap = capOverrides.TryGetValue(name, out var ov) ? ov : weeklyCapDefault; + + 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, + BoughtThisWeek = weeklyPurchased.TryGetValue(name, out var bw) ? bw : 0, + EffectiveCap = effectiveCap, + }); + } + return result; + } +``` + +- [ ] **Step 3: Add `LoadCoveredMaterialsAsync`** + +Add after `LoadPricingTraceAsync`: + +```csharp + public async Task> LoadCoveredMaterialsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandTimeout = 30; + cmd.CommandText = + "DECLARE @ws DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); " + + "SELECT " + + " ed.definitionname, " + + " ISNULL(rmp.unit_price, 0) AS current_price, " + + " COALESCE(o.weekly_cap_override, CAST(cfg.param_value AS BIGINT)) AS effective_cap, " + + " o.weekly_cap_override, " + + " ISNULL(o.create_buy_orders, 1) AS create_buy_orders, " + + " ISNULL(o.create_sell_orders, 1) AS create_sell_orders, " + + " ISNULL(wt.qty_purchased, 0) AS bought_this_week " + + "FROM entitydefaults ed " + + "LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname " + + "LEFT JOIN ( " + + " SELECT resource_name, unit_price FROM resource_market_prices " + + " WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) " + + ") rmp ON rmp.resource_name = ed.definitionname " + + "LEFT JOIN ( " + + " SELECT definitionname, SUM(qty_purchased) AS qty_purchased " + + " FROM automarket_rawmat_weekly_tracking WHERE week_start >= @ws " + + " GROUP BY definitionname " + + ") wt ON wt.definitionname = ed.definitionname " + + "CROSS JOIN (SELECT param_value FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default') cfg " + + "WHERE (ed.categoryflags & 276) = 276 AND ed.enabled = 1 AND ed.hidden = 0 " + + "ORDER BY ed.definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var capOverride = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); + var buy = reader.GetBoolean(4); + var sell = reader.GetBoolean(5); + result.Add(new AutoMarketCoveredMaterialRow + { + DefinitionName = reader.GetString(0), + CurrentPrice = reader.IsDBNull(1) ? 0.0 : (double)reader.GetDecimal(1), + EffectiveCap = reader.GetInt64(2), + WeeklyCapOverride = capOverride, + CreateBuyOrders = buy, + CreateSellOrders = sell, + BoughtThisWeek = reader.GetInt64(6), + OriginalCapOverride = capOverride, + OriginalBuyOrders = buy, + OriginalSellOrders = sell, + }); + } + return result; + } +``` + +- [ ] **Step 4: Build** + +``` +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 "IMPROVEMENT-040: repository — rename view ref, expand PricingTrace, add LoadCoveredMaterialsAsync" +``` + +--- + +## Task 8: Statistics VM + XAML — add Pricing Trace columns + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/AutoMarketStatisticsView.xaml` + +The Statistics VM (`AutoMarketStatisticsViewModel`) needs no code changes — `LoadPricingTraceAsync` now returns rows with `BoughtThisWeek` and `EffectiveCap` populated. Only the XAML needs two new columns. + +- [ ] **Step 1: Add columns to the Pricing Trace DataGrid** + +In `AutoMarketStatisticsView.xaml`, find the Pricing Trace `` block and add after the `Stored Price` column: + +```xml + + +``` + +- [ ] **Step 2: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors, no BAML errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/Views/AutoMarketStatisticsView.xaml +git commit -m "IMPROVEMENT-040: Statistics Pricing Trace — add Bought/Week and Weekly Cap columns" +``` + +--- + +## Task 9: Raw Materials VM + View + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml.cs` + +- [ ] **Step 1: Create `AutoMarketRawMaterialsViewModel.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; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketRawMaterialsViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + private readonly TranslationsViewModel? _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredRows))] + private bool _showOverridesOnly; + + public ObservableCollection Rows { get; } = new(); + + public System.Collections.Generic.IEnumerable FilteredRows => + _showOverridesOnly ? Rows.Where(r => r.HasOverride) : (System.Collections.Generic.IEnumerable)Rows; + + public AutoMarketRawMaterialsViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + TranslationsViewModel? translations = null) + { + _repo = repo; + _queue = queue; + _translations = translations; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading raw materials..."; + StatusIsError = false; + try + { + var rows = await _repo.LoadCoveredMaterialsAsync(); + var store = _translations?.Store; + + 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; + } + if (string.IsNullOrEmpty(r.DisplayName)) r.DisplayName = r.DefinitionName; + Rows.Add(r); + } + + OnPropertyChanged(nameof(FilteredRows)); + StatusMessage = $"Loaded {Rows.Count} materials 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) => RefreshCommand.NotifyCanExecuteChanged(); + + [RelayCommand] + private void QueueSave(AutoMarketCoveredMaterialRow row) + { + var description = $"automarket_rawmat_overrides: {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + + string sql; + if (row.IsAtDefaults) + { + sql = $"DELETE FROM automarket_rawmat_overrides WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}"; + } + else + { + sql = + $"MERGE automarket_rawmat_overrides AS t " + + $"USING (VALUES ({SqlLiteral.Of(row.DefinitionName)}, {SqlLiteral.OfNullableInt(row.WeeklyCapOverride)}, " + + $"{(row.CreateBuyOrders ? 1 : 0)}, {(row.CreateSellOrders ? 1 : 0)})) " + + $"AS s (definitionname, weekly_cap_override, create_buy_orders, create_sell_orders) " + + $"ON t.definitionname = s.definitionname " + + $"WHEN MATCHED THEN UPDATE SET " + + $" weekly_cap_override = s.weekly_cap_override, " + + $" create_buy_orders = s.create_buy_orders, " + + $" create_sell_orders = s.create_sell_orders " + + $"WHEN NOT MATCHED THEN INSERT (definitionname, weekly_cap_override, create_buy_orders, create_sell_orders) " + + $"VALUES (s.definitionname, s.weekly_cap_override, s.create_buy_orders, s.create_sell_orders);"; + } + + _queue.Add(new RawSqlChange(description, sql)); + row.OriginalCapOverride = row.WeeklyCapOverride; // not init; these are mutable for display + StatusMessage = $"{row.DisplayName} queued."; + OnPropertyChanged(nameof(FilteredRows)); + } + } +} +``` + +- [ ] **Step 2: Create `AutoMarketRawMaterialsView.xaml.cs`** + +```csharp +using System.Windows.Controls; + +namespace Perpetuum.AdminTool.Views +{ + public partial class AutoMarketRawMaterialsView : UserControl + { + public AutoMarketRawMaterialsView() + { + InitializeComponent(); + } + } +} +``` + +- [ ] **Step 3: Create `AutoMarketRawMaterialsView.xaml`** + +```xml + + + + + + +