From b599d25db30b6c85da670d52842f187812b5e4ae Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 08:21:20 +0500 Subject: [PATCH 01/58] fix: remove buyOrderDeposit, buyOrderPayBack, TransportAssignmentSubmit from season wallet hooks (ISSUE-022) --- src/Perpetuum/Accounting/Characters/CharacterWallet.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Perpetuum/Accounting/Characters/CharacterWallet.cs b/src/Perpetuum/Accounting/Characters/CharacterWallet.cs index 20bb361..9e15305 100644 --- a/src/Perpetuum/Accounting/Characters/CharacterWallet.cs +++ b/src/Perpetuum/Accounting/Characters/CharacterWallet.cs @@ -55,7 +55,6 @@ protected override void OnCommited(double startBalance) case TransactionType.hangarRent: case TransactionType.hangarRentAuto: case TransactionType.marketFee: - case TransactionType.buyOrderDeposit: case TransactionType.corporationCreate: case TransactionType.extensionLearn: case TransactionType.ItemRepair: @@ -73,12 +72,10 @@ protected override void OnCommited(double startBalance) case TransactionType.ResearchKitMerge: case TransactionType.ProductionCPRGForge: case TransactionType.ItemShopBuy: - case TransactionType.TransportAssignmentSubmit: case TransactionType.ItemShopCreditTake: 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: From be60cbceaf6bf8d99930956b0ef0140383048b30 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 08:22:31 +0500 Subject: [PATCH 02/58] docs: mark ISSUE-022 as DONE --- docs/backlog/issues.md | 57 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index ff8ce06..5513d39 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -1,6 +1,61 @@ # Last ID used -021 +023 + +## ISSUE-023 - Editing existing Season objectives does not save 'Is Daily' flag changes + +Status: TODO +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 From 5c1009eca6146f867461431d14cafa4c68dad4c6 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 08:31:42 +0500 Subject: [PATCH 03/58] docs: add ISSUE-022 design spec and implementation plan Co-Authored-By: Claude Sonnet 4.6 --- ...5-issue-022-market-order-season-exploit.md | 181 ++++++++++++++++++ ...-022-market-order-season-exploit-design.md | 91 +++++++++ 2 files changed, 272 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-issue-022-market-order-season-exploit.md create mode 100644 docs/superpowers/specs/2026-05-25-issue-022-market-order-season-exploit-design.md 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/specs/2026-05-25-issue-022-market-order-season-exploit-design.md b/docs/superpowers/specs/2026-05-25-issue-022-market-order-season-exploit-design.md new file mode 100644 index 0000000..6e1871f --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-issue-022-market-order-season-exploit-design.md @@ -0,0 +1,91 @@ +# Design: ISSUE-022 — Market Order Season Points Exploit Fix + +**Date:** 2026-05-25 +**Status:** Approved +**Area:** Seasons / Activities / Market + +--- + +## Problem + +`CharacterWallet.OnCommited` records season activity points based on wallet transaction type. Two transaction types are incorrectly included: + +- `buyOrderDeposit` → fires `NicSpent` +- `buyOrderPayBack` → fires `NicEarned` + +**Exploit cycle:** +1. Player places a buy order with no matching sell order → `buyOrderDeposit` transaction → NicSpent recorded for the full deposit amount +2. Player waits 10 minutes (enforced by `MARKET_CANCEL_TIME`) +3. Player cancels → `buyOrderPayBack` transaction → NicEarned recorded for the full deposit amount +4. Repeat — net real cost per cycle: only the non-refundable market fee + +The 10-minute lock is the only barrier. The exploit is unlimited and zero net economic commitment. + +A related inflation bug also exists: `TransportAssignmentSubmit` is in the wallet's `NicSpent` case, AND `TransportAssignment.CashInOnSubmit` calls `RecordActivity(NicSpent)` explicitly. The owner gets 2× NicSpent per transport assignment submission. + +--- + +## Root Cause + +`CharacterWallet.OnCommited` is a blunt instrument — it maps transaction types to activity without distinguishing "money held in escrow" from "money irreversibly spent". The ISSUE-020 fix added correct explicit hooks in `Market.cs` at actual fulfillment, but `buyOrderDeposit` and `buyOrderPayBack` were never removed from the wallet switch. + +--- + +## Scope + +Two transaction types removed from `NicSpent` case, one from `NicEarned` case, in one file. + +No new hooks. No other files change. + +--- + +## Design + +### Change: `CharacterWallet.cs` — remove three `case` lines + +**File:** `src/Perpetuum/Accounting/Characters/CharacterWallet.cs` + +Remove from the `NicSpent` case: +- `case TransactionType.buyOrderDeposit:` +- `case TransactionType.TransportAssignmentSubmit:` + +Remove from the `NicEarned` case: +- `case TransactionType.buyOrderPayBack:` + +### Why each removal is safe + +| Removed entry | Why safe | +|---|---| +| `buyOrderDeposit` | A deposit is money held in escrow. When the buy order is fulfilled, `Market.cs` already records `NicSpent` with the correct amount and counterparty. If cancelled, no spend occurred. | +| `buyOrderPayBack` | Returning a deposit is not income. No legitimate NicEarned should result from an unfulfilled order cancellation. | +| `TransportAssignmentSubmit` | `TransportAssignment.CashInOnSubmit` calls `RecordActivity(NicSpent)` explicitly on the same line as the wallet debit. The wallet hook is the duplicate; the explicit hook is the correct call site. | + +### What stays + +`marketFee` remains in `NicSpent`. Market fees are non-refundable on both buy and sell orders, so they represent a real irreversible economic cost. The slow-farm potential (repeatedly paying fees to earn points) is accepted as low-severity. + +All other wallet switch entries are untouched. + +--- + +## Verification + +Manual validation steps: + +1. **Buy order, no match → cancel:** Place a buy order at a price that finds no sell order. Wait 10 minutes. Cancel. Verify no NicSpent was awarded at placement and no NicEarned was awarded at cancellation. +2. **Buy order, immediate match:** Place a buy order that immediately matches an existing sell order. Verify NicSpent IS awarded (via `Market.cs` explicit hook), not doubled. +3. **Transport assignment submission:** Submit a transport assignment as owner. Verify NicSpent is awarded exactly once (not twice). +4. **Sell order market fee:** Place a sell order, cancel after 10 minutes. Verify NicSpent was awarded for the market fee (small amount) and no other activity was recorded. + +--- + +## Affected Systems + +- `CharacterWallet.OnCommited` — the only changed site +- Season activity tracking (NicSpent, NicEarned objectives) +- Market buy order flow (deposit / payback) +- Transport assignment submission + +## Regression Risk + +Low. The change removes incorrect points awards; it does not alter any financial logic or fulfillment flow. The only regression risk is if some objective was specifically designed around `buyOrderDeposit` NicSpent — which would be a misconfigured objective given the exploit it enables. From 73d6825867c2d46ee45fb4fdc5c4fe9d28b53448 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 08:52:19 +0500 Subject: [PATCH 04/58] fix: replace DataGridCheckBoxColumn with template column for IsDaily to ensure source updates before Queue Save (ISSUE-023) DataGridCheckBoxColumn defers binding source updates to CommitCellEdit, which is not reliably triggered when clicking the Queue Save button in the same row. The IsDaily property was never updated on the row object before QueueSaveObjective read it, so the old value was written to the database. Replacing with DataGridTemplateColumn + CheckBox (UpdateSourceTrigger=PropertyChanged) ensures IsDaily is set immediately on toggle, independent of DataGrid edit state. Co-Authored-By: Claude Sonnet 4.6 --- docs/backlog/issues.md | 2 +- src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index 5513d39..2004ebe 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -4,7 +4,7 @@ ## ISSUE-023 - Editing existing Season objectives does not save 'Is Daily' flag changes -Status: TODO +Status: DONE Priority: CRITICAL Area: Seasons / Admin Tool diff --git a/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml b/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml index 3f1bfee..29a2771 100644 --- a/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml +++ b/src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml @@ -237,9 +237,14 @@ - + + + + + + + From cc9cb6f9efbd3587155020547dbdb10b50ea7937 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 12:35:09 +0500 Subject: [PATCH 05/58] docs: add IMPROVEMENT-029 design spec for Discord pinned announcements Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-25-improvement-029-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-improvement-029-design.md diff --git a/docs/superpowers/specs/2026-05-25-improvement-029-design.md b/docs/superpowers/specs/2026-05-25-improvement-029-design.md new file mode 100644 index 0000000..038742d --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-improvement-029-design.md @@ -0,0 +1,192 @@ +# IMPROVEMENT-029 Design: Pin Daily Activity Announcements in Discord + +**Date:** 2026-05-25 +**Status:** Approved +**Area:** Seasons / Announcements / Discord Integration + +--- + +## Problem + +Daily pool and leaderboard announcements are sent to the integrated Discord channel but quickly get buried by subsequent chat volume. Players miss the current day's active objectives. Pinning the messages keeps them visible regardless of chat activity. + +--- + +## Scope + +Pin two announcement types: +- **Daily pool** (`AnnounceDailyPool`) — pinned under slot `DailyPool` +- **Leaderboard** (`AnnounceLeaderboard`) — pinned under slot `Leaderboard` + +Each slot holds its own pin independently. All other announcement types (season start/end, objective complete, tier unlock) are unchanged. + +--- + +## Integration Context + +The server uses a **Discord.Net bot token** (`DiscordBotToken` in `GlobalConfiguration`). The `Manage Messages` permission is required on the target channel. Webhook-based integrations cannot pin; the bot token path already in use supports it. + +--- + +## Architecture + +### New Types + +**`PinSlot` enum** — `Perpetuum.Services.EventServices.EventMessages` +```csharp +public enum PinSlot { DailyPool = 0, Leaderboard = 1 } +``` + +**`DiscordPinnableMessage`** — `Perpetuum.Services.EventServices.EventMessages` +```csharp +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; } +} +``` + +Reuses `EventType.PerpetuumToDiscord` — same directional intent as `DiscordIntegrationMessage`. No new `EventType` value needed. + +--- + +### Data Layer + +**New table: `discord_pin_state`** +```sql +CREATE TABLE discord_pin_state ( + pin_slot TINYINT NOT NULL, + discord_channel_id BIGINT NOT NULL, + discord_message_id BIGINT NOT NULL, + CONSTRAINT PK_discord_pin_state PRIMARY KEY (pin_slot) +) +``` + +One row per `PinSlot` value. Survives server restarts, enabling clean unpin on the next announcement cycle. + +**`IDiscordPinStateRepository`** +```csharp +public interface IDiscordPinStateRepository +{ + Task<(ulong channelId, ulong messageId)?> GetAsync(PinSlot slot); + Task UpsertAsync(PinSlot slot, ulong channelId, ulong messageId); +} +``` + +Implementation follows existing repository patterns (parameterized queries, `IDbConnectionFactory`). Registered in Autofac in `PerpetuumBootstrapper`. + +--- + +### `ChannelManager` Changes + +New method added to `IChannelManager` and `ChannelManager`: + +```csharp +void PinnedAnnouncement(string channelName, Character sender, string message, PinSlot pinSlot) +``` + +Identical in-game broadcast logic to `Announcement()`. Discord dispatch difference: publishes `DiscordPinnableMessage` instead of `DiscordIntegrationMessage`. Existing `Announcement()` method is unchanged. + +--- + +### `EventListenerService` Changes + +`IDiscordPinStateRepository` added as a constructor parameter. + +New branch in `PublishMessage()`: + +```csharp +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 = await _pinStateRepository.GetAsync(pinnableMessage.PinSlot); + if (existing.HasValue) + { + try + { + var oldMsg = await discordChannel.GetMessageAsync(existing.Value.messageId); + if (oldMsg is IUserMessage oldUserMsg) + await oldUserMsg.UnpinAsync(); + } + catch { /* log warning — unpin failure does not block new pin */ } + } + + try { await sent.PinAsync(); } + catch { /* log warning */ } + + await _pinStateRepository.UpsertAsync( + pinnableMessage.PinSlot, + pinnableMessage.DiscordChannelId, + sent.Id); + }); + } +} +``` + +Failure modes: +- **Unpin fails** (e.g. old message deleted externally): caught, logged, new pin proceeds. +- **Pin fails** (e.g. missing `Manage Messages` permission): caught, logged, message ID still persisted so the next cycle can attempt unpin. +- **Send fails**: uncaught — consistent with existing fire-and-forget behavior in `PublishMessage`. + +`Task.Run(async () => { ... })` pattern is consistent with existing async Discord calls in `Start()` and `Stop()`. + +--- + +### `SeasonService` Changes + +Two call sites changed: + +| Method | Before | After | +|---|---|---| +| `AnnounceDailyPool()` | `Announcement(SeasonChannelName, sender, msg)` | `PinnedAnnouncement(SeasonChannelName, sender, msg, PinSlot.DailyPool)` | +| `AnnounceLeaderboard()` | `Announcement(SeasonChannelName, sender, msg)` | `PinnedAnnouncement(SeasonChannelName, sender, msg, PinSlot.Leaderboard)` | + +All other `Announcement` call sites in `SeasonService` are unchanged. + +--- + +## Files Affected + +| File | Change | +|---|---| +| `src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs` | New | +| `src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs` | New | +| `src/Perpetuum/Repositories/DiscordPinStateRepository.cs` | New | +| `src/Perpetuum/Services/EventServices/EventListenerService.cs` | Add constructor param, new PublishMessage branch | +| `src/Perpetuum/Services/Channels/ChannelManager.cs` | New `PinnedAnnouncement` method | +| `src/Perpetuum/Services/Channels/IChannelManager.cs` | New interface method | +| `src/Perpetuum/Services/Seasons/SeasonService.cs` | Two call sites | +| `src/Perpetuum.Server/PerpetuumBootstrapper.cs` | Autofac registration | +| DB migration | `CREATE TABLE discord_pin_state` | + +--- + +## Manual Validation Steps + +1. Start server with a configured Discord bot token and `Manage Messages` permission on the target channel. +2. Trigger `AnnounceDailyPool` — verify the message appears in Discord and is pinned. +3. Trigger `AnnounceDailyPool` a second time — verify the previous pin is removed and the new message is pinned. +4. Trigger `AnnounceLeaderboard` — verify it pins independently (daily pool pin remains). +5. Manually delete the pinned Discord message and trigger another announcement — verify the unpin failure is logged but the new message still pins successfully. +6. Remove `Manage Messages` permission from the bot and trigger an announcement — verify the message sends, the pin failure is logged, and the server does not crash or block. +7. Restart the server and trigger an announcement — verify the previous pin is correctly unpinned (DB-persisted ID used). + +--- + +## Potential Regressions + +- `ChannelManager.Announcement()` is unchanged — all non-pinned announcement paths are unaffected. +- `EventListenerService.PublishMessage()` existing `DiscordIntegrationMessage` branch is unchanged. +- No changes to in-game chat delivery logic. +- Discord message format (`****: message`) is identical between pinned and non-pinned paths. From e989b7f341ea4ed05043bca64a6d073fa615d680 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 12:50:02 +0500 Subject: [PATCH 06/58] docs: add IMPROVEMENT-029 implementation plan for Discord pinned announcements Co-Authored-By: Claude Sonnet 4.6 --- ...vement-029-discord-pinned-announcements.md | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md 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..b3449ac --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md @@ -0,0 +1,503 @@ +# 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: 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 2: 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 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/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: 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 2: 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: + +```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 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/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. From fd0410e6ff7ef6f500a0ca0fb35211bf24253399 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 12:55:04 +0500 Subject: [PATCH 07/58] feat: add discord_pin_state table migration (IMPROVEMENT-029) --- .../migrations/add_discord_pin_state.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/db_structure/migrations/add_discord_pin_state.sql 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 From daeb7cf940d79b2e0e5462ee9baa0bfeeb2cd3bd Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 12:57:55 +0500 Subject: [PATCH 08/58] feat: add PinSlot enum and DiscordPinnableMessage (IMPROVEMENT-029) --- .../EventMessages/DiscordPinnableMessage.cs | 19 +++++++++++++++++++ .../EventServices/EventMessages/PinSlot.cs | 8 ++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs create mode 100644 src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs diff --git a/src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs b/src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs new file mode 100644 index 0000000..de50501 --- /dev/null +++ b/src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs @@ -0,0 +1,19 @@ +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; + } + } +} diff --git a/src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs b/src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs new file mode 100644 index 0000000..171c786 --- /dev/null +++ b/src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs @@ -0,0 +1,8 @@ +namespace Perpetuum.Services.EventServices.EventMessages +{ + public enum PinSlot + { + DailyPool = 0, + Leaderboard = 1, + } +} From be352fd92e2a1e275ae38ac0455bee97d30afca8 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 13:01:11 +0500 Subject: [PATCH 09/58] feat: add DiscordPinStateRepository (IMPROVEMENT-029) --- .../DiscordPinStateRepository.cs | 44 +++++++++++++++++++ .../IDiscordPinStateRepository.cs | 10 +++++ 2 files changed, 54 insertions(+) create mode 100644 src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs create mode 100644 src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs diff --git a/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs b/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs new file mode 100644 index 0000000..a629896 --- /dev/null +++ b/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs @@ -0,0 +1,44 @@ +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(); + } + } +} diff --git a/src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs b/src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs new file mode 100644 index 0000000..e313957 --- /dev/null +++ b/src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs @@ -0,0 +1,10 @@ +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); + } +} From a50098394f0f5ca3658277dd63f11834fb295fce Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 13:04:03 +0500 Subject: [PATCH 10/58] fix: use ulong.TryParse in DiscordPinStateRepository.Get (IMPROVEMENT-029) --- .../EventServices/DiscordPinStateRepository.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs b/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs index a629896..4a8218b 100644 --- a/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs +++ b/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs @@ -16,10 +16,14 @@ public class DiscordPinStateRepository : IDiscordPinStateRepository if (record == null) return null; - return ( - ulong.Parse(record.GetValue("discord_channel_id")), - ulong.Parse(record.GetValue("discord_message_id")) - ); + var rawChannel = record.GetValue("discord_channel_id"); + var rawMessage = record.GetValue("discord_message_id"); + + if (!ulong.TryParse(rawChannel, out var channelId) || + !ulong.TryParse(rawMessage, out var messageId)) + return null; + + return (channelId, messageId); } public void Upsert(PinSlot slot, ulong channelId, ulong messageId) From 887ee8c0067a01a9724fd6e25c27f3158a617edb Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 13:54:58 +0500 Subject: [PATCH 11/58] feat: add PinnedAnnouncement to ChannelManager (IMPROVEMENT-029) Co-Authored-By: Claude Sonnet 4.6 --- .../Services/Channels/ChannelManager.cs | 19 +++++++++++++++++++ .../Services/Channels/IChannelManager.cs | 2 ++ 2 files changed, 21 insertions(+) diff --git a/src/Perpetuum/Services/Channels/ChannelManager.cs b/src/Perpetuum/Services/Channels/ChannelManager.cs index 0a9cc14..4f32c77 100644 --- a/src/Perpetuum/Services/Channels/ChannelManager.cs +++ b/src/Perpetuum/Services/Channels/ChannelManager.cs @@ -358,6 +358,25 @@ public void Announcement(string channelName, Character sender, string message, C channel.SendToOne(_sessionManager, recipient, builder); } + 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)); + } + } + public void KickOrBan(string channelName, Character issuer, Character character, string message, bool ban) { if (issuer == character) diff --git a/src/Perpetuum/Services/Channels/IChannelManager.cs b/src/Perpetuum/Services/Channels/IChannelManager.cs index fffcf93..0f3dc53 100644 --- a/src/Perpetuum/Services/Channels/IChannelManager.cs +++ b/src/Perpetuum/Services/Channels/IChannelManager.cs @@ -1,5 +1,6 @@ using Perpetuum.Accounting.Characters; using Perpetuum.Host.Requests; +using Perpetuum.Services.EventServices.EventMessages; namespace Perpetuum.Services.Channels { @@ -30,6 +31,7 @@ public interface IChannelManager /// Message string /// Member to receive the announcement. null - for all members 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); void UnBan(string channelName, Character issuer, Character character); From 03034ba7bd11134f6e81b308dedeba9f90ba3f49 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 14:31:10 +0500 Subject: [PATCH 12/58] feat: handle DiscordPinnableMessage with pin/unpin logic in EventListenerService (IMPROVEMENT-029) Co-Authored-By: Claude Sonnet 4.6 --- .../EventServices/EventListenerService.cs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Perpetuum/Services/EventServices/EventListenerService.cs b/src/Perpetuum/Services/EventServices/EventListenerService.cs index 5dfcca3..e81f454 100644 --- a/src/Perpetuum/Services/EventServices/EventListenerService.cs +++ b/src/Perpetuum/Services/EventServices/EventListenerService.cs @@ -20,8 +20,9 @@ public class EventListenerService : Process //private readonly DiscordSocketClient _client; private readonly GlobalConfiguration _globalConfiguration; + private readonly IDiscordPinStateRepository _pinStateRepository; - public EventListenerService(GlobalConfiguration globalConfiguration) + public EventListenerService(GlobalConfiguration globalConfiguration, IDiscordPinStateRepository pinStateRepository) { _observers = new Dictionary>(); _queue = new ConcurrentQueue(); @@ -32,6 +33,7 @@ public EventListenerService(GlobalConfiguration globalConfiguration) }); _globalConfiguration = globalConfiguration; + _pinStateRepository = pinStateRepository; } /// @@ -51,6 +53,39 @@ public void PublishMessage(IEventMessage message) 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); From 297b7db2d13dc1addfa11722fb784d1a3f86ed09 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 14:34:44 +0500 Subject: [PATCH 13/58] fix: guard Task.Run body against SendMessageAsync failure in EventListenerService (IMPROVEMENT-029) --- .claude/worktrees/agent-a4eb0fb25973f7611 | 1 + CHANGELOG-p36.3.txt | 74 +++++++++++++++++++ .../EventServices/EventListenerService.cs | 40 +++++----- 3 files changed, 97 insertions(+), 18 deletions(-) create mode 160000 .claude/worktrees/agent-a4eb0fb25973f7611 create mode 100644 CHANGELOG-p36.3.txt diff --git a/.claude/worktrees/agent-a4eb0fb25973f7611 b/.claude/worktrees/agent-a4eb0fb25973f7611 new file mode 160000 index 0000000..c92cfb5 --- /dev/null +++ b/.claude/worktrees/agent-a4eb0fb25973f7611 @@ -0,0 +1 @@ +Subproject commit c92cfb59ac5434477549fd6a2ea7d13f0800b287 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/src/Perpetuum/Services/EventServices/EventListenerService.cs b/src/Perpetuum/Services/EventServices/EventListenerService.cs index e81f454..6b6a12a 100644 --- a/src/Perpetuum/Services/EventServices/EventListenerService.cs +++ b/src/Perpetuum/Services/EventServices/EventListenerService.cs @@ -59,30 +59,34 @@ public void PublishMessage(IEventMessage message) { 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 { - try + 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) { - var oldMsg = await discordChannel.GetMessageAsync(existing.Value.messageId); - if (oldMsg is IUserMessage oldUserMsg) - await oldUserMsg.UnpinAsync(); + try + { + var oldMsg = await discordChannel.GetMessageAsync(existing.Value.messageId); + if (oldMsg is IUserMessage oldUserMsg) + await oldUserMsg.UnpinAsync(); + } + catch { } } + + try { await sent.PinAsync(); } catch { } - } - try { await sent.PinAsync(); } + _pinStateRepository.Upsert( + pinnableMessage.PinSlot, + pinnableMessage.DiscordChannelId, + sent.Id); + } catch { } - - _pinStateRepository.Upsert( - pinnableMessage.PinSlot, - pinnableMessage.DiscordChannelId, - sent.Id); }); } } From a8fbd4b4d868e7b825a82d9ae9330f056ce05f01 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 14:36:39 +0500 Subject: [PATCH 14/58] feat: register DiscordPinStateRepository in Autofac (IMPROVEMENT-029) --- src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs index 7b93c44..fdf8752 100644 --- a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs +++ b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs @@ -578,6 +578,8 @@ private void InitRelayManager() e.Context.Resolve().AddProcess(e.Instance.ToAsync().AsTimed(TimeSpan.FromMinutes(1))); }); + _ = _builder.RegisterType().As().SingleInstance(); + // OPP: EventListenerService and consumers _ = _builder.RegisterType(); _ = _builder.RegisterType(); From 75e6773b4a0f65a5dbe4576e72c0abc346ada035 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 14:40:28 +0500 Subject: [PATCH 15/58] feat: pin daily pool and leaderboard announcements in Discord (IMPROVEMENT-029) --- src/Perpetuum/Services/Seasons/SeasonService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Perpetuum/Services/Seasons/SeasonService.cs b/src/Perpetuum/Services/Seasons/SeasonService.cs index 515e63a..f186be4 100644 --- a/src/Perpetuum/Services/Seasons/SeasonService.cs +++ b/src/Perpetuum/Services/Seasons/SeasonService.cs @@ -2,6 +2,7 @@ using Perpetuum.Data; using Perpetuum.EntityFramework; using Perpetuum.Services.Channels; +using Perpetuum.Services.EventServices.EventMessages; using Perpetuum.Services.Mail; using Perpetuum.Services.Sessions; using Perpetuum.Threading.Process; @@ -398,7 +399,7 @@ internal void AnnounceLeaderboard(Season? season) chatMessage.AppendLine(); chatMessage.AppendLine("Way to go, Agents!"); - _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, chatMessage.ToString()); + _channelManager.Value.PinnedAnnouncement(SeasonChannelName, _announcer.Value, chatMessage.ToString(), PinSlot.Leaderboard); } private static ImmutableHashSet SelectDailyPool( @@ -428,7 +429,7 @@ private void AnnounceDailyPool(IReadOnlyList pool, int totalDai sb.AppendLine($" — {obj.Name}: {obj.Description}"); sb.AppendLine(); sb.AppendLine("Complete them for bonus season points and rewards!"); - _channelManager.Value.Announcement(SeasonChannelName, _announcer.Value, sb.ToString()); + _channelManager.Value.PinnedAnnouncement(SeasonChannelName, _announcer.Value, sb.ToString(), PinSlot.DailyPool); } // ── Mail helpers ───────────────────────────────────────────────────── From 30ed11aae40e2f469260a3593378b6dfa7c0e298 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 14:48:03 +0500 Subject: [PATCH 16/58] fix: address final review issues for IMPROVEMENT-029 --- docs/backlog/improvements.md | 2 +- .../database_schema_documentation.md | 20 +++++++++++++++++++ .../DiscordPinStateRepository.cs | 2 +- .../EventServices/EventListenerService.cs | 4 +++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index 20d32e0..0a17993 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -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 diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index 3b93254..af301d4 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -2339,6 +2339,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` diff --git a/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs b/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs index 4a8218b..686f643 100644 --- a/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs +++ b/src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs @@ -29,7 +29,7 @@ public class DiscordPinStateRepository : IDiscordPinStateRepository public void Upsert(PinSlot slot, ulong channelId, ulong messageId) { Db.Query( - "MERGE discord_pin_state AS t " + + "MERGE discord_pin_state WITH (HOLDLOCK) 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 " + diff --git a/src/Perpetuum/Services/EventServices/EventListenerService.cs b/src/Perpetuum/Services/EventServices/EventListenerService.cs index 6b6a12a..3b20ee4 100644 --- a/src/Perpetuum/Services/EventServices/EventListenerService.cs +++ b/src/Perpetuum/Services/EventServices/EventListenerService.cs @@ -71,7 +71,9 @@ public void PublishMessage(IEventMessage message) { try { - var oldMsg = await discordChannel.GetMessageAsync(existing.Value.messageId); + var unpinChannel = _client.GetChannel(existing.Value.channelId) as IMessageChannel + ?? discordChannel; + var oldMsg = await unpinChannel.GetMessageAsync(existing.Value.messageId); if (oldMsg is IUserMessage oldUserMsg) await oldUserMsg.UnpinAsync(); } From daff981ef731e6473ae2ac4720e4756ad363b243 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Mon, 25 May 2026 14:59:13 +0500 Subject: [PATCH 17/58] docs: add code graph check steps to plan and CLAUDE.md Required Workflow Addresses gap identified in IMPROVEMENT-029 session: graph checks were not included in the written plan, allowing subagents to skip them. Plan now has explicit query-graph.ps1 steps before Tasks 4 and 5. CLAUDE.md step 7 now requires graph check steps in plans for interface/class edits. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- ...vement-029-discord-pinned-announcements.md | 37 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) 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/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md b/docs/superpowers/plans/2026-05-25-improvement-029-discord-pinned-announcements.md index b3449ac..4fd3310 100644 --- 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 @@ -219,7 +219,18 @@ git commit -m "feat: add DiscordPinStateRepository (IMPROVEMENT-029)" - Modify: `src/Perpetuum/Services/Channels/IChannelManager.cs` - Modify: `src/Perpetuum/Services/Channels/ChannelManager.cs` -- [ ] **Step 1: Add the method to `IChannelManager.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: @@ -241,7 +252,7 @@ void PinnedAnnouncement(string channelName, Character sender, string message, Pi void KickOrBan(string channelName, Character issuer, Character character, string message, bool ban); ``` -- [ ] **Step 2: Implement the method in `ChannelManager.cs`** +- [ ] **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: @@ -266,7 +277,7 @@ public void PinnedAnnouncement(string channelName, Character sender, string mess } ``` -- [ ] **Step 3: Build to verify no errors** +- [ ] **Step 4: Build to verify no errors** ``` dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 @@ -274,7 +285,7 @@ dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 Expected: build succeeds with zero errors. -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```bash git add src/Perpetuum/Services/Channels/IChannelManager.cs @@ -289,7 +300,15 @@ git commit -m "feat: add PinnedAnnouncement to ChannelManager (IMPROVEMENT-029)" **Files:** - Modify: `src/Perpetuum/Services/EventServices/EventListenerService.cs` -- [ ] **Step 1: Add the repository field and update the constructor** +- [ ] **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`: @@ -315,9 +334,9 @@ public EventListenerService(GlobalConfiguration globalConfiguration, IDiscordPin } ``` -- [ ] **Step 2: Add the `DiscordPinnableMessage` branch in `PublishMessage`** +- [ ] **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: +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) @@ -373,7 +392,7 @@ public void PublishMessage(IEventMessage message) } ``` -- [ ] **Step 3: Build to verify no errors** +- [ ] **Step 4: Build to verify no errors** ``` dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 @@ -381,7 +400,7 @@ dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 Expected: build succeeds with zero errors. -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```bash git add src/Perpetuum/Services/EventServices/EventListenerService.cs From d5015a4cc2b4ca98366bca69d9c302cfb5a7943f Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Wed, 27 May 2026 22:39:01 +0500 Subject: [PATCH 18/58] docs: add IMPROVEMENT-030 AutoMarket overhaul spec and backlog entry Covers NIC injection control (daily budget cap, fractional buy quantity), zone-aware gather tracking (is_pvp flag), dynamic risk-aware raw material pricing (plasma-anchored supply/demand formula), and AutoMarket performance and thread-safety refactoring scope. Co-Authored-By: Claude Sonnet 4.6 --- docs/backlog/improvements.md | 53 +++- .../2026-05-27-automarket-overhaul-design.md | 271 ++++++++++++++++++ 2 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-05-27-automarket-overhaul-design.md diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index 0a17993..d509160 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -1,6 +1,6 @@ # Last ID used -029 +030 ## IMPROVEMENT-002 - Refactor Hardcoded System Characters and Channels @@ -442,3 +442,54 @@ 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: TODO +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. + +### 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. diff --git a/docs/superpowers/specs/2026-05-27-automarket-overhaul-design.md b/docs/superpowers/specs/2026-05-27-automarket-overhaul-design.md new file mode 100644 index 0000000..3e69655 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-automarket-overhaul-design.md @@ -0,0 +1,271 @@ +# IMPROVEMENT-030 — AutoMarket Overhaul Design + +**Date:** 2026-05-27 +**Status:** Approved +**Backlog entry:** `docs/backlog/improvements.md#IMPROVEMENT-030` + +--- + +## Problem Summary + +The AutoMarket has three interconnected problems: + +### 1. Plasma buy orders are an unbounded NIC faucet + +When a player sells plasma to an AutoMarket buy order, `Market.FiniteVendorBuyOrderTakesTheItem` / `FulfillSellOrderInstantly` calls `PayOutToSeller`, which directly increments the player's wallet balance. There is no vendor wallet being drained. `CentralBank.SubAmount` is called afterward but it is a pure accounting ledger, not a real balance. **Every plasma sale creates new NIC unconditionally.** + +Additionally, the buy order quantity equals 100% of plasma gathered in the past 7 days (`cdp.gathered` from `fn_CalculateDynamicPlasmaPrices`). This is procyclical: more farming → larger buy orders → more NIC created per refresh cycle. There is no daily spending limit. + +The price mechanism (`MIN + (MAX–MIN) × (1 – sold/gathered)`) can compress per-unit income over time but does not reduce total NIC injection — the bot still offers to buy the full gathered quantity at the compressed price. + +### 2. Raw material prices are backwards and static + +`recalculate_raw_material_prices` distributes total plasma NIC proportionally across gathered resource volumes: + +``` +price_i = total_plasma_nic × (qty_i / total_all_resources) +``` + +This means **more supply → higher price**, which is the opposite of supply/demand. Common, easy-to-gather materials get relatively high prices; rare materials gathered in small volumes get low prices. + +The static `raw_material_prices` table acts as both fallback and clamp anchor. It requires manual maintenance and does not reflect zone risk (alpha materials priced the same as PvP-zone materials). + +### 3. Performance and thread-safety concerns + +`MarketAutoOrdersManager.Update(time)` fires timer callbacks synchronously on the process loop. `usp_RefreshAutoMarketOrders` uses four SQL cursors (alpha, beta, gamma plasma, raw materials) that execute row-by-row. These are blocking, potentially long-running DB operations on the main process thread. The gather recording call sites in modules also make synchronous DB calls outside the zone transaction but still from the zone processing path. + +--- + +## Solution Design + +### Part A — NIC Injection Control + +#### A1. `automarket_config` table + +New single-row-per-param config table replacing all hardcoded constants: + +```sql +CREATE TABLE automarket_config ( + param_name VARCHAR(100) NOT NULL PRIMARY KEY, + param_value FLOAT NOT NULL +); + +INSERT INTO automarket_config VALUES + ('plasma_anchor_fraction', 0.15), -- fraction of alpha plasma price = raw mat floor + ('plasma_buy_qty_fraction', 0.60), -- buy 60% of gathered, not 100% + ('daily_plasma_budget_nic', 500000),-- max NIC paid out for plasma per calendar day + ('resource_ds_ratio_min', 0.25), -- supply/demand ratio floor clamp + ('resource_ds_ratio_max', 4.0); -- supply/demand ratio ceiling clamp +``` + +#### A2. `usp_RefreshAutoMarketOrders` — plasma buy order changes + +Before the alpha/beta/gamma cursor blocks: + +```sql +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; +``` + +For each cursor row, the quantity inserted becomes: + +```sql +-- Adjusted quantity: fraction of gathered, capped by remaining budget +DECLARE @adjusted_qty BIGINT = CAST(@quantity * @buy_qty_fraction AS BIGINT); +DECLARE @budget_qty BIGINT = CASE WHEN @unit_price > 0 + THEN CAST(@remaining_budget / @unit_price AS BIGINT) + ELSE 0 END; +SET @quantity = CASE WHEN @adjusted_qty < @budget_qty + THEN @adjusted_qty ELSE @budget_qty END; +-- Skip insert if budget exhausted +IF @quantity <= 0 CONTINUE; +``` + +#### A3. `MarketAutoOrdersManager.cs` — refresh interval + +Change `RecalculatePricesAndRenewOrders` timer from `TimeSpan.FromDays(3)` to `TimeSpan.FromDays(1)`. Prices now respond within 24 hours. + +--- + +### Part B — Zone-Aware Gather Tracking + +#### B1. Schema: add `is_pvp` column + +```sql +ALTER TABLE resources_gathered_daily + ADD is_pvp BIT NOT NULL DEFAULT 0; + +ALTER TABLE resources_gathered + ADD is_pvp BIT NOT NULL DEFAULT 0; +``` + +The existing PK/unique index on `resources_gathered` must be updated to include `is_pvp` if one exists; verify before applying. + +#### B2. `sp_RecordResourceGathered` — add `@is_pvp` parameter + +```sql +ALTER PROCEDURE sp_RecordResourceGathered + @gathered_on DATE, + @resource_name VARCHAR(100), + @quantity BIGINT, + @is_pvp BIT = 0 -- default 0 = PvE (backward-compatible) +AS +BEGIN + SET NOCOUNT ON; + INSERT INTO resources_gathered_daily (gathered_on, resource_name, quantity, is_pvp) + VALUES (@gathered_on, @resource_name, @quantity, @is_pvp); +END; +``` + +#### B3. `consolidate_statistics` — preserve `is_pvp` in merge key + +```sql +-- In the resources block, change GROUP BY and MERGE ON: +GROUP BY gathered_on, resource_name, is_pvp + +ON target.gathered_on = source.gathered_on +AND target.resource_name = source.resource_name +AND target.is_pvp = source.is_pvp + +-- INSERT must include is_pvp: +INSERT (gathered_on, resource_name, quantity, is_pvp) +VALUES (source.gathered_on, source.resource_name, source.total_quantity, source.is_pvp) +``` + +#### B4. C# call sites — pass zone PvP flag + +`ZoneConfiguration.Protected == true` → alpha (PvE), `Protected == false` → beta/gamma (PvP). + +Five call sites need updating: + +| File | Line | Change | +|---|---|---| +| `DrillerModule.cs` | 210 | Add `.SetParameter("@is_pvp", !zone.Configuration.Protected)` | +| `HarvesterModule.cs` | 160 | Same | +| `LargeDrillerModule.cs` | 131 | Same | +| `LargeHarvesterModule.cs` | 102 | Same | +| `LootContainer.cs` | 637 | Verify zone context available; pass flag or default to `false` | + +Verify the `zone` variable is in scope at each call site before the patch. The stored proc defaults `@is_pvp = 0`, so any call site that cannot determine zone type can simply omit the parameter. + +--- + +### Part C — Dynamic Risk-Aware Raw Material Pricing + +#### C1. Revise `recalculate_raw_material_prices` + +Replace the current proportional distribution formula with a supply/demand + PvP-risk formula anchored to live plasma prices. Remove the `raw_material_prices` clamp. + +**New formula (per resource, over past 7 days):** + +``` +pve_qty = SUM(quantity WHERE is_pvp = 0) +pvp_qty = SUM(quantity WHERE is_pvp = 1) +total_qty = pve_qty + pvp_qty +supply_daily_avg = total_qty / 7.0 + +demand = SUM(v_required_raw_materials.total_quantity) / 7.0 -- daily average demand from AutoMarket + +ds_ratio = CLAMP(ds_ratio_min, ds_ratio_max, demand / NULLIF(supply_daily_avg, 0)) + -- no gather data → NULL / NULL → NULL → clamped to ds_ratio_max (max scarcity) + +pvp_ratio = CAST(pvp_qty AS FLOAT) / NULLIF(total_qty, 0) +risk = 1.0 + ISNULL(pvp_ratio, 1.0) + -- no gather data → pvp_ratio NULL → risk = 2.0 (max, assume dangerous) + +plasma_anchor = fn_CalculateDynamicPlasmaPrices(1).dynamic_price + × automarket_config['plasma_anchor_fraction'] + +price = ROUND(plasma_anchor × ds_ratio × risk, 2) +``` + +**Price range:** `anchor × 0.25 × 1.0` to `anchor × 4.0 × 2.0` = 0.25×–8× of anchor. + +**Reference examples** (alpha plasma at 75 NIC, anchor = 11.25 NIC): + +| Material | daily supply | daily demand | ds_ratio | PvP% | risk | Price | +|---|---|---|---|---|---|---| +| Common alpha ore, plentiful | 3× demand | — | 0.33 | 0% | 1.0 | ~3.7 NIC | +| Mixed beta ore, balanced | equal | — | 1.0 | 60% | 1.6 | ~18 NIC | +| PvP gamma ore, scarce | 0.25× | — | 4.0 | 100% | 2.0 | ~90 NIC | +| Never gathered | zero | any | 4.0 (max) | unknown | 2.0 (max) | ~90 NIC | + +**Fallback for materials with zero gather history**: the formula naturally handles this — `supply_daily_avg = 0` causes `ds_ratio` to hit the ceiling (4.0), and `pvp_ratio` is NULL → `risk = 2.0`. This correctly signals "nobody is gathering this → scarce and risky." + +#### C2. Update `v_all_production_costs` — remove `raw_material_prices` fallback + +The current view uses: +```sql +ISNULL(mp.unit_price, base.price_nic) -- base = raw_material_prices +``` + +Once `recalculate_raw_material_prices` always produces a price (including for ungathered materials), change to: +```sql +ISNULL(mp.unit_price, ) +``` + +Where `` is an inline computation of `plasma_anchor × 4.0 × 2.0` for materials completely absent from `resource_market_prices`. This makes the view self-contained. + +The `raw_material_prices` table rows are left intact as historical reference but no longer read by any active query path. + +--- + +### Part D — Performance and Thread-Safety Refactoring + +#### D1. Analysis scope + +During implementation, evaluate the following before writing any fixes: + +1. **`MarketAutoOrdersManager.Update(time)` on the process loop** — determine which thread drives this `IProcess`. If it shares the main server process loop, the blocking `ConsolidateStatistics` (every 15 min) and `RecalculatePricesAndRenewOrders` (now daily) will stall that loop for the duration of the DB calls. Measure or estimate DB operation duration and assess whether offloading to `Task.Run` is warranted. If yes, follow existing async patterns in the codebase (do not use fire-and-forget; capture exceptions). + +2. **Cursor-based plasma buy order insertion** — `usp_RefreshAutoMarketOrders` uses four SQL cursors that execute row-by-row. The alpha/beta/gamma plasma sections could be replaced with set-based `INSERT ... SELECT` joining `fn_CalculateDynamicPlasmaPrices` results directly to the Markets CTE and vendor table — eliminating the cursor loop entirely. The raw materials section can similarly become a single set-based INSERT. Evaluate and rewrite as set-based if the cursor approach is confirmed as a performance bottleneck. + +3. **`resources_gathered_daily` insert frequency** — `sp_RecordResourceGathered` is called per-gather event from five module types. On active servers these are frequent zone-thread calls. Verify the `READPAST` hint in `consolidate_statistics` is sufficient to prevent lock contention between concurrent inserts and the 15-minute merge. No code change required if contention is absent; note the finding regardless. + +4. **DELETE-all + INSERT-all pattern in `usp_RefreshAutoMarketOrders`** — every refresh deletes all `isAutoOrder = 1` rows and re-inserts. This causes index churn. Evaluate whether a MERGE-based approach (insert new, update changed, delete removed) would reduce churn. Note: on a small server the table is unlikely to be large, so this may be low priority. + +#### D2. Refactoring rules + +- Preserve all existing public contracts (market order IDs, packet formats, vendor behavior visible to clients). +- Do not introduce new static service locators. +- Any async changes must follow patterns used in `EventListenerService` or similar — no unguarded `Task.Run` without exception logging. +- Cursor → set-based SQL rewrites must produce identical observable results: same orders placed, same prices, same quantities. + +--- + +## Schema Change Summary + +| Object | Change type | Detail | +|---|---|---| +| `automarket_config` | New table | Config params replacing hardcoded constants | +| `resources_gathered_daily` | Alter | Add `is_pvp BIT NOT NULL DEFAULT 0` | +| `resources_gathered` | Alter | Add `is_pvp BIT NOT NULL DEFAULT 0`; update unique index to include `is_pvp` | +| `sp_RecordResourceGathered` | Alter | Add `@is_pvp BIT = 0` parameter | +| `consolidate_statistics` | Alter | Include `is_pvp` in GROUP BY and MERGE key | +| `recalculate_raw_material_prices` | Rewrite | New formula; remove `raw_material_prices` dependency | +| `usp_RefreshAutoMarketOrders` | Alter | Budget cap, fractional quantity, set-based if warranted | +| `v_all_production_costs` | Alter | Remove `raw_material_prices` fallback | +| `raw_material_prices` | Deprecated | Left in DB; removed from all active query paths | + +--- + +## Validation Steps + +1. Run `usp_RefreshAutoMarketOrders` manually after deploy; confirm auto buy orders appear for plasma on all alpha/beta/gamma markets with quantity ≤ 60% of last-7-day gathered. +2. Confirm `automarket_config` rows are present and readable. +3. Gather a small amount of resources in an alpha zone and a beta zone. Wait for `consolidate_statistics` to run (≤15 min). Verify `resources_gathered` has separate rows for `is_pvp = 0` and `is_pvp = 1` for the same resource. +4. Run `recalculate_raw_material_prices` manually. Verify `resource_market_prices` has entries for all materials in `v_required_raw_materials`. Verify no material has a NULL or zero price. +5. Query `v_all_production_costs`. Verify no row has NULL `production_cost_nic`. Verify PvP-sourced materials have higher prices than equivalent PvE materials when supply/demand ratio is equal. +6. Sell plasma to the AutoMarket until `plasma_sold.income` for today exceeds `daily_plasma_budget_nic`. Verify subsequent buy orders are either absent or have zero quantity. +7. Build passes with no warnings in modified C# files. + +--- + +## Regression Risk + +- **Market order visibility to clients**: auto orders are re-inserted on refresh; no client-side IDs are persisted, so no regression. +- **Production cost calculations**: `v_all_production_costs` is used by `usp_RefreshAutoMarketOrders` for sell order prices. Any price change in the view directly affects what the bot charges for items and robots. Verify item sell prices are in a reasonable range after deploy. +- **`consolidate_statistics` key change**: adding `is_pvp` to the merge key means historical rows (pre-deploy) without `is_pvp` will default to `0`. This is correct — all pre-existing gather data is treated as PvE. No data loss. +- **Modules passing `@is_pvp`**: the stored proc parameter defaults to `0`, so any call site missed during the update will silently treat gathers as PvE. Safe but imprecise; verify all five sites. From b21bf63fcbb56fc31f6b11a753a3bc28606272f9 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:07:52 +0500 Subject: [PATCH 19/58] feat(db): add automarket_config table (IMPROVEMENT-030) --- .../database_schema_documentation.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index af301d4..c9bf8f7 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,29 @@ 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 | +|---|---| +| `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` | + +--- + ## automarket_unbought_resources **Schema:** `dbo` From 48bdd5ae5fa4fe03c488812450ec69f821b876c7 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:09:35 +0500 Subject: [PATCH 20/58] feat(db): add is_pvp column to resources_gathered tables (IMPROVEMENT-030) --- docs/db_structure/database_schema_documentation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index c9bf8f7..bb247f1 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -5774,6 +5774,7 @@ Stores the Discord channel ID and message ID of the currently pinned message per | `gathered_on` | `date [not null]` | | `resource_name` | `varchar(100) [not null]` | | `quantity` | `bigint [not null]` | +| `is_pvp` | `bit [not null, default: 0]` | --- @@ -5788,6 +5789,7 @@ Stores the Discord channel ID and message ID of the currently pinned message per | `gathered_on` | `date [not null]` | | `resource_name` | `varchar(100) [not null]` | | `quantity` | `bigint [not null]` | +| `is_pvp` | `bit [not null, default: 0]` | --- From 532bf742af2f96ccbd79361bb240248eb1feb3ae Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:10:24 +0500 Subject: [PATCH 21/58] feat(db): add @is_pvp param to sp_RecordResourceGathered (IMPROVEMENT-030) --- ...RecordResourceGathered.StoredProcedure.sql | Bin 1114 -> 1188 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 3fd76a84cfa347ebce2c4afd95eac6a322300a7c..0caa1bb176d7843d77a49c2e7747d687d2c4892a 100644 GIT binary patch delta 419 zcmY*WT}uK{5IwsWp%#4VJua2l%388v2oj_zA&Gsg^(qV5DumjHMnXSA=Dnw0{2Ts^ z-h1d@c=pC@lY_hzylvmr(X zAF>|ek;oM$bd4!%11f1e5D%&C$d46C%ic?CqBT^|Mjf7f*k`h^w>vSqvML&UY6C5H zc-%R`PtCR+&YaK_G>I{*P4t9bAgh9aXO%OImMG+!1-9d~WQ9a&A>k`u_U W3ijjP|5II**uEvns-z$+C>b?r>9qri0iZtP6z-49*Ni453?)EPfuWe807#ZF z5ROSJ(*OwT^Jl0LKs{b zbQpLiS2H;SMfWnvD?2fG0{!R7;0Kfg>2PES2GdSJl@M{>$=1x8tPs Date: Thu, 28 May 2026 08:11:11 +0500 Subject: [PATCH 22/58] feat(db): include is_pvp in consolidate_statistics MERGE key (IMPROVEMENT-030) --- ...consolidate_statistics.StoredProcedure.sql | Bin 4848 -> 3790 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 b251883afdc750f87b9c6478c4e3ee31843f21b1..249f0ad2b82619e3908578d77f22f813870d846a 100644 GIT binary patch delta 830 zcmZ`&T~8BH5Isw_A8ePF-3TgRxZ04oSgI~!j3kX3KcX?T;0rYYU1}-#1);48zW5u+ zJev^x1sb2!MEwIk`8$mI?1Sgt61#k5l9{tJXJ+p`v-@l8`-!cdQwayRP~Mt&n-6gb zMV!MFeaIN%A{54Bc2-i>Sf@dztjUyVg(Gb%8g_6M_u$|fZgAjj%n+-N$)}#tS5EgC z6EuZ6)X~Hm+SowNV1*?-Wu}HFxJ*Wb25p12262l~;Std)S)0U5>|LhS$x+NcCufbQ z$^0`~#QY`-%(O-bBQsdj8iwA)1FYi(*5$lCDSvEV9=a~$kZjw>O#ESw=-9W)kvm)z z;L5u$d_bQer8L#ZmvvuluYLWOG%VFV>$9I)i8eacznu^ zfSgSub08C|%DesnDW&yEeR-UIDewC)7+ma~_x7GAkXf%P+4PY7?JeeYH7`3%qw_ZK_+t3M*RWI@hIenpm%;Lai%|t=+qj*O{YMaNrK! zxFK&eZ*H}iaO7^^9fRFuK?bvfnXaN*<&*L;8y=$cY)4)Bkh?;ijvS)fxSlFsJzGki YFLT~DyB7Si@9?9Z!F8>R{#)q(0g?r;zW@LL literal 4848 zcmd^DTTc@~6h5z-_#fu6AcEG*1I7pFmP$ffZHvSh)6{l>N}<@@iu`%?`_3@z?5zq$ z36jlrXJ^j+eCM2nAK!PSD|IpQK~5!u(1#;`TUdvL);{ZDYM%42zs-zuI}d4b(3cGvOt48K)e#`9rW*3>gbp@)+V zX~BnW*wB()*}>JtZX0iY931sq(6qr(VLUrJNd0>d-|_gE(~{ zrG|9?c|7^EI8nm}?B;V5(i*VE+J8?T;bNAow=LD%hE`f_{WOT@2p6+vG@AyIW=6l_ zKGB$m@(s@!D2<*($nQi?BGAOhmXUp&Oo1X=;q;D`pE%nG9dmdh2cSK}Dwad6XxR?# zn%d)V5JHaC9U-$;;vyejDh=rzW~|@aiZ*H{a*CwWZsOPaoJH@US{cMW})Bx8L z@DAk&>tbY+Y5*G;*&(PyedF#NF*evAK-NUgv3Az^HIP`xe6VguHbEsh}di`sSX#>J)o*5uM1_;KO&G4{46+LKg&hu zj?UWXqCP^6v0tnd%5*&0$n?Q6Q2Z?B>!J#pu=fi*W{zUTYK%ZbtduoxntmGruN$fc6*}7%zohd3SSg})%HFmT< zokth9hFZG|EzJFU{N^pR(a-yn#MqC6)#LBD?&C07RZKR4!Wpy?b+$uHvGTFFf%gi$ z;Z!eEj1D>-dndbH51lP+ zU>{ASua-pC$SY9#a>^)u`8I!?TR~1w7I#UWigcAz`}+1!^PF-9)xrr@(|77-H{gj+ z?a)PB1xn8hx!#?vB5PGTR(YC-kdThj?^b4FJ0~9}-c@`c3TSypIV&YuT!GZ`Nb)r6 zQ5wlEV($>G9rUC+bXxQ$-M$PLl%9^vVoTDri=wzBWivIIFi*9b1Jp4mo1HEEa`uHRG3?B{m{t9Xhj=g)AW*hjMMT{koBPZ7}p;>X@s zY*&(nyE5nfnDl|Rb4 Date: Thu, 28 May 2026 08:14:07 +0500 Subject: [PATCH 23/58] feat: pass @is_pvp to sp_RecordResourceGathered from all gather modules (IMPROVEMENT-030) Co-Authored-By: Claude Sonnet 4.6 --- src/Perpetuum/Modules/DrillerModule.cs | 3 ++- src/Perpetuum/Modules/HarvesterModule.cs | 3 ++- src/Perpetuum/Modules/LargeDrillerModule.cs | 3 ++- src/Perpetuum/Modules/LargeHarvesterModule.cs | 3 ++- src/Perpetuum/Services/Looting/LootContainer.cs | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Perpetuum/Modules/DrillerModule.cs b/src/Perpetuum/Modules/DrillerModule.cs index f860712..4d9818c 100644 --- a/src/Perpetuum/Modules/DrillerModule.cs +++ b/src/Perpetuum/Modules/DrillerModule.cs @@ -207,10 +207,11 @@ remoteControlledCreature.CommandRobot is Player ownerPlayer try { Db.Query() - .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") .SetParameter("@gathered_on", DateTime.UtcNow) .SetParameter("@resource_name", resourceName) .SetParameter("@quantity", quantity) + .SetParameter("@is_pvp", !zone.Configuration.Protected) .ExecuteNonQuery(); } catch (Exception ex) diff --git a/src/Perpetuum/Modules/HarvesterModule.cs b/src/Perpetuum/Modules/HarvesterModule.cs index 73af15f..9eeca01 100644 --- a/src/Perpetuum/Modules/HarvesterModule.cs +++ b/src/Perpetuum/Modules/HarvesterModule.cs @@ -157,10 +157,11 @@ remoteControlledCreature.CommandRobot is Player ownerPlayer try { Db.Query() - .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") .SetParameter("@gathered_on", DateTime.UtcNow) .SetParameter("@resource_name", resourceName) .SetParameter("@quantity", quantity) + .SetParameter("@is_pvp", !zone.Configuration.Protected) .ExecuteNonQuery(); } catch (Exception ex) diff --git a/src/Perpetuum/Modules/LargeDrillerModule.cs b/src/Perpetuum/Modules/LargeDrillerModule.cs index 29d4438..3697ef0 100644 --- a/src/Perpetuum/Modules/LargeDrillerModule.cs +++ b/src/Perpetuum/Modules/LargeDrillerModule.cs @@ -128,10 +128,11 @@ public override void DoExtractMinerals(IZone zone) try { Db.Query() - .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") .SetParameter("@gathered_on", DateTime.UtcNow) .SetParameter("@resource_name", resourceName) .SetParameter("@quantity", quantity) + .SetParameter("@is_pvp", !zone.Configuration.Protected) .ExecuteNonQuery(); } catch (Exception ex) diff --git a/src/Perpetuum/Modules/LargeHarvesterModule.cs b/src/Perpetuum/Modules/LargeHarvesterModule.cs index 5674435..12dcf29 100644 --- a/src/Perpetuum/Modules/LargeHarvesterModule.cs +++ b/src/Perpetuum/Modules/LargeHarvesterModule.cs @@ -99,10 +99,11 @@ remoteControlledCreature.CommandRobot is Player ownerPlayer try { Db.Query() - .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") .SetParameter("@gathered_on", DateTime.UtcNow) .SetParameter("@resource_name", resourceName) .SetParameter("@quantity", quantity) + .SetParameter("@is_pvp", !zone.Configuration.Protected) .ExecuteNonQuery(); } catch (Exception ex) diff --git a/src/Perpetuum/Services/Looting/LootContainer.cs b/src/Perpetuum/Services/Looting/LootContainer.cs index 92cd850..c689428 100644 --- a/src/Perpetuum/Services/Looting/LootContainer.cs +++ b/src/Perpetuum/Services/Looting/LootContainer.cs @@ -634,10 +634,11 @@ public LootContainer Build(IZone zone, Position position) try { Db.Query() - .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") .SetParameter("@gathered_on", DateTime.UtcNow) .SetParameter("@resource_name", fragment.Key) .SetParameter("@quantity", fragment.Sum(x => x.ItemInfo.Quantity)) + .SetParameter("@is_pvp", !zone.Configuration.Protected) .ExecuteNonQuery(); } catch (Exception ex) From 8d4b2be0ae9398d385dfd786a6ced553ea77adaa Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:17:54 +0500 Subject: [PATCH 24/58] feat(db): rewrite recalculate_raw_material_prices with supply/demand + PvP-risk formula (IMPROVEMENT-030) Co-Authored-By: Claude Sonnet 4.6 --- ...te_raw_material_prices.StoredProcedure.sql | Bin 11424 -> 7568 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 6eab0784383da17578ce87b2185caa7e88af09bd..500cdd8d79d8e505a1905c78e9b1b30b1616e04f 100644 GIT binary patch literal 7568 zcmdT}Yi}A?5S`Ed75gnXjbqnRn?55Pz;0UGxPH{Cs%k|IcHDwZ48-xj?>ToE&%Lm~ zl3=M7f?bw-=W*tFhch{mhGg=G^ra(r;d3Y>ym~T_mi#GevL{V^Zp!oMmEqZ6@;CNe z$WXomg$$I2atj(AY0D9=xA=|sqwtJ8+SqdonlEwBbr;~!#pej0E$r?{j^{18$F(

Q&U0q>2V;XwYulgof{Z;V+MR$s`wN940$g$xw0 zvA2UAq}~E2Qsy^DaSgiFAa4WOQy=m>hHf?7*P$hUS+(8P!0QBj-+=ZW@FKo$OSQ#e zkf8|)h^t!Rd#v-7#JmO@?Tlrt2CduB@Zpgfsn#Dg%j8FVuLbU>QUP!BI>Wp6xT+%S zSVQ@I=em%yNjS~-9vF9EnH&fY!T)aThlZ%~H92;`s}An`{=2*Y-Hk`?M|gG&c`9-a z*&6aov?|t*GSWA;!~XP_`Leo%IXp}w&NZcu#Au$ZuV(O^hYY9${l7qg`Fa5Sn9Z4k z=|PmA)>K=XE}w!yOFjbY|GFoX!+^R>W8e|OYQQYhg=f73dRg$x9y~$gVOGATR}Zjy zh+Xu~*b|-lbcTxQrT+UVh@T=>kHGy)yxU1Niz~FtV-f7Jc?o~d5y_LaKdp^NQPpBM zVU@oE&g)T>8T~Yxz2fD->j6l6JhRGPLIP$3L!Exyfkezp>|v;j(@3q!9wL)!g8y`0 zOY4=|ycRh2LvAt)veL5$7=XXljvL(F<7r%}t?tw6UA!`^WuKIV49CvN`XqXYW)$-i z(z%7LMnOBvy3EL}u+nXHj(U8GtmseT{=_QdqWtW;%{F$OjvvdlFCw0{+O8+4(g&yt zTDu$2Pi=h|G-VFzqYCu#jrn>DZ|cCFER*-pnsLGmML)0`S7OYt_vwDNe4CK)Rp{DRBdN+4d$D#t*O2uA(zamj zs{-HiY&Ge#^tUVQ)9B)_?SlPrHkw?W114SGjr zfXRw%(d4_=(XaWp_L~@bse{?W|L; z(JW(Y(`^+vrMh}0Vznx#rQ>FD*|mrGo~=*pueQUkJe%@N$P?6T`nI}?n&34&?R*yR zehy8o;w)ZmJiRgvpyi^xlLbG>ppi}-c+%zcpxj=qcI9O=8NCWs$2~lK`C|!0jRMbN ztf9;EULH~7X;(+M4TNW956v{?aQp#?I}+ME6=B3*me=^{AZ|+QrJ~BDu>m2N!r_Uyq@}KJqV6{oq>5R_l`yT&nt9P!^j3_hzQc(19ji!9Y8z{a>P`3PANEfQ<^W) zYfhf_*|BLKtVmRGnw_4cI$h4^>yRX+JI~HF;KDh+O=5Mfxr50wJ+=Y_EP zA7ibG(6k<#-NAD7T^Fxm2SK`8IImJW%iq4%Je-}sO=j6|>!f^yn#7ZB9CwOms>i(O zbZIzuCl2MMqJK~p&aIhIXWNkkM5#Qk~95u7_w4DzC|A22)zm?5S+$vhQoe>bLjhU*T&QP zWU@u?WZuz#X1uFUyV}XqrJ)+8jC!3KIT(J|4dMB;yC|5hQ%@xTgAns(`ziF etufhb<q6^`1ka8{`Qvi=MEupk`( literal 11424 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 ZBtLn(&wxewCtV+2Yf=YD7IW_#{|7ZhVr2jT From 88e0774fde4a4d38d189acd3237b9ff1fc80e4d0 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:32:29 +0500 Subject: [PATCH 25/58] fix(db): add NULL guard for demand, remove unused pve_qty in recalculate_raw_material_prices (IMPROVEMENT-030) --- ...te_raw_material_prices.StoredProcedure.sql | Bin 7568 -> 5244 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 500cdd8d79d8e505a1905c78e9b1b30b1616e04f..871478828697183ee73cae56eba0693e63a05837 100644 GIT binary patch literal 5244 zcmcgwZExE)5dQ98ao?JxZITAqx^-(2)lyPzj^$bMilQh4R#FnJlBMb;Y5x75BSpQ~ zPVH<#3D~sBBk#H6y^#yoqK|o0L9=-e66(SdB_eEug2s#oMzSrLUaE3zO8 z>ms6$;pgnr;m0BhL=x1As3N}*w|5$6?|n0Q4ezZ@R_ba--n z@-sCIjz;8K9vM^D_NR-<#3g5{An@>C3&*oe-!`qOXP?>DT+fc^IU1RFOOeI_m35vc zcSm8Aie*SoX?8PvUc}`Wy3C3c=twMsYbimMQ4-%olqaH01u2-L5sl}T;aN1BJ7ddS z%%POs1RRm!j_9p*ZUeS%41Ma1oyF7xVEPUF>t!W+x+J=({N;o?jF-^Z>@D_gtiZ4`a+%Ssd##(VqmPs22^rD=SiXPAWUFg7#h z%;&vj{i!D!V9f0)J|~j89o)oJ^7BI+@OGvzSGU_BggPt#vJgQPXG?5)HgOCO{cC*o zx)t~*n`=$1G0*3s5UGD7lA58M&7BK$qOP)36knps53=QDe1)<1?<|~wUH}_ndfxqs zvI~C&!;_BbX2pl4pT<8JY7v!LT?CO2=7oT&7{X3S#W#rh1C_b~sra^nw9_#B+DP(i zK|z+LIRCz@k|?RVjv9aN*0dflIKKx-={Yk{zrZI{F-1kNjss?7Ypm*W>5p~fYogFh zhLjlxKKRrN+FxJX+MOiztGm24yfC_ieX_uQ5yA4a!dJ2k^h9(36sIfZIfyV&-#AP2 z8t%x!u+GZ};b0lvHo=Z#B8em+#B0*=x+GXHD>g5(uvUH-T#I{XV$5{f!dgz-4J^To~8 z`hhf-uMe*DuLLXxWx+bBwsTG4%P{^%9@lA?FV+{H&z%K3;@c1S(wuxYk&br1e{w42 z`+-b8-?v5Hu~(wVOr(sfIR5j)TPP6abqp068 zCB>D+R$u;ps?t1n?H>o*8;wntSAA>`Ih$3?O&dhtC0A_0ruChS&s_QqXm^9RzuUei_wUBw zW!OZGwx#Oq=Ye#6$BA{u*PuTfn~S8jMALGW_<`%Fa;`xdz!-<1wIIBW#UT4~_?O$W zYp-OUt^4hiMq|>mTkO6M)6JS%venHEmlG4 z%e8f7$Gizw7lt=}XE6?rHQh7@WLwOd7XW^aAgY&frOXR|__SLL8|PCis2KQs(g!y`Q)y;H}8Y=0Y*g~e%ek9wMy*`WozzGpkt$^y6Szq|0QD2c?f z&M8YmLJeA#VDalQUPAaGrpS^cUS82{ybQD3{@u;Q>w{1+6~ B&r|>a literal 7568 zcmdT}Yi}A?5S`Ed75gnXjbqnRn?55Pz;0UGxPH{Cs%k|IcHDwZ48-xj?>ToE&%Lm~ zl3=M7f?bw-=W*tFhch{mhGg=G^ra(r;d3Y>ym~T_mi#GevL{V^Zp!oMmEqZ6@;CNe z$WXomg$$I2atj(AY0D9=xA=|sqwtJ8+SqdonlEwBbr;~!#pej0E$r?{j^{18$F(

Q&U0q>2V;XwYulgof{Z;V+MR$s`wN940$g$xw0 zvA2UAq}~E2Qsy^DaSgiFAa4WOQy=m>hHf?7*P$hUS+(8P!0QBj-+=ZW@FKo$OSQ#e zkf8|)h^t!Rd#v-7#JmO@?Tlrt2CduB@Zpgfsn#Dg%j8FVuLbU>QUP!BI>Wp6xT+%S zSVQ@I=em%yNjS~-9vF9EnH&fY!T)aThlZ%~H92;`s}An`{=2*Y-Hk`?M|gG&c`9-a z*&6aov?|t*GSWA;!~XP_`Leo%IXp}w&NZcu#Au$ZuV(O^hYY9${l7qg`Fa5Sn9Z4k z=|PmA)>K=XE}w!yOFjbY|GFoX!+^R>W8e|OYQQYhg=f73dRg$x9y~$gVOGATR}Zjy zh+Xu~*b|-lbcTxQrT+UVh@T=>kHGy)yxU1Niz~FtV-f7Jc?o~d5y_LaKdp^NQPpBM zVU@oE&g)T>8T~Yxz2fD->j6l6JhRGPLIP$3L!Exyfkezp>|v;j(@3q!9wL)!g8y`0 zOY4=|ycRh2LvAt)veL5$7=XXljvL(F<7r%}t?tw6UA!`^WuKIV49CvN`XqXYW)$-i z(z%7LMnOBvy3EL}u+nXHj(U8GtmseT{=_QdqWtW;%{F$OjvvdlFCw0{+O8+4(g&yt zTDu$2Pi=h|G-VFzqYCu#jrn>DZ|cCFER*-pnsLGmML)0`S7OYt_vwDNe4CK)Rp{DRBdN+4d$D#t*O2uA(zamj zs{-HiY&Ge#^tUVQ)9B)_?SlPrHkw?W114SGjr zfXRw%(d4_=(XaWp_L~@bse{?W|L; z(JW(Y(`^+vrMh}0Vznx#rQ>FD*|mrGo~=*pueQUkJe%@N$P?6T`nI}?n&34&?R*yR zehy8o;w)ZmJiRgvpyi^xlLbG>ppi}-c+%zcpxj=qcI9O=8NCWs$2~lK`C|!0jRMbN ztf9;EULH~7X;(+M4TNW956v{?aQp#?I}+ME6=B3*me=^{AZ|+QrJ~BDu>m2N!r_Uyq@}KJqV6{oq>5R_l`yT&nt9P!^j3_hzQc(19ji!9Y8z{a>P`3PANEfQ<^W) zYfhf_*|BLKtVmRGnw_4cI$h4^>yRX+JI~HF;KDh+O=5Mfxr50wJ+=Y_EP zA7ibG(6k<#-NAD7T^Fxm2SK`8IImJW%iq4%Je-}sO=j6|>!f^yn#7ZB9CwOms>i(O zbZIzuCl2MMqJK~p&aIhIXWNkkM5#Qk~95u7_w4DzC|A22)zm?5S+$vhQoe>bLjhU*T&QP zWU@u?WZuz#X1uFUyV}XqrJ)+8jC!3KIT(J|4dMB;yC|5hQ%@xTgAns(`ziF etufhb<q6^`1ka8{`Qvi=MEupk`( From b6a5169f4bb463301f47a2a5f23f31e9eb0252c8 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:34:15 +0500 Subject: [PATCH 26/58] feat(db): remove raw_material_prices fallback from v_all_production_costs (IMPROVEMENT-030) Co-Authored-By: Claude Sonnet 4.6 --- .../views/v_all_production_costs.sql | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/db_structure/views/v_all_production_costs.sql b/docs/db_structure/views/v_all_production_costs.sql index 1ebc1f6..3ed8ae1 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,44 @@ 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, (SELECT price FROM max_scarcity_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 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, (SELECT price FROM max_scarcity_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 ), 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 From 44ca77bf9b47f527be431bf7c13165c9f50fa0b3 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:45:22 +0500 Subject: [PATCH 27/58] feat(db): add budget cap, fractional qty, set-based inserts to usp_RefreshAutoMarketOrders (IMPROVEMENT-030) Co-Authored-By: Claude Sonnet 4.6 --- ...efreshAutoMarketOrders.StoredProcedure.sql | Bin 30182 -> 21960 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 e0a50a2e851bd323c610068f8a9f678cddb08424..6b64873bbf712cfd1d9b788146d259e230399972 100644 GIT binary patch literal 21960 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 literal 30182 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 From 0e1b731c4d22428b76732cc6803c98e08ceae544 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:49:00 +0500 Subject: [PATCH 28/58] feat: reduce AutoMarket refresh to 1 day and offload DB ops from main loop (IMPROVEMENT-030) Callbacks on TimerList fire synchronously on the MainLoop thread; wrap ConsolidateStatistics and RecalculatePricesAndRenewOrders in Task.Run with Logger.Exception to prevent stalling the process loop. Co-Authored-By: Claude Sonnet 4.6 --- .../MarketEngine/MarketAutoOrdersManager.cs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs b/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs index 6f754e4..f40feb4 100644 --- a/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs +++ b/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs @@ -1,4 +1,5 @@ using Perpetuum.Data; +using Perpetuum.Log; using Perpetuum.Threading.Process; using Perpetuum.Timers; using System.Transactions; @@ -26,12 +27,32 @@ public void Update(TimeSpan time) private void Init() { - _timers.Add(new TimerAction(ConsolidateStatistics, TimeSpan.FromMinutes(15))); - _timers.Add(new TimerAction(RecalculatePricesAndRenewOrders, TimeSpan.FromDays(3))); + // Callbacks fire synchronously on the MainLoop thread (ProcessManager.UpdateLoop). + // The async wrappers offload blocking DB work to the thread pool to avoid stalling the loop. + _timers.Add(new TimerAction(ConsolidateStatisticsAsync, TimeSpan.FromMinutes(15))); + _timers.Add(new TimerAction(RecalculatePricesAndRenewOrdersAsync, TimeSpan.FromDays(1))); // Debug purposes, do not uncomment - //_timers.Add(new TimerAction(ConsolidateStatistics, TimeSpan.FromMinutes(15))); - //_timers.Add(new TimerAction(RecalculatePricesAndRenewOrders, TimeSpan.FromMinutes(3))); + //_timers.Add(new TimerAction(ConsolidateStatisticsAsync, TimeSpan.FromMinutes(15))); + //_timers.Add(new TimerAction(RecalculatePricesAndRenewOrdersAsync, TimeSpan.FromMinutes(3))); + } + + private void ConsolidateStatisticsAsync() + { + Task.Run(() => + { + try { ConsolidateStatistics(); } + catch (Exception ex) { Logger.Exception(ex); } + }); + } + + private void RecalculatePricesAndRenewOrdersAsync() + { + Task.Run(() => + { + try { RecalculatePricesAndRenewOrders(); } + catch (Exception ex) { Logger.Exception(ex); } + }); } private void ConsolidateStatistics() From 660abfd91ea024f14a3f6cb533cbf5f3430221ce Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:51:36 +0500 Subject: [PATCH 29/58] fix: add concurrent-execution guards to MarketAutoOrdersManager async wrappers (IMPROVEMENT-030) Co-Authored-By: Claude Sonnet 4.6 --- .../Services/MarketEngine/MarketAutoOrdersManager.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs b/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs index f40feb4..8ea1238 100644 --- a/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs +++ b/src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs @@ -9,6 +9,8 @@ namespace Perpetuum.Services.MarketEngine public class MarketAutoOrdersManager : IProcess { private readonly TimerList _timers = new TimerList(); + private volatile bool _consolidating; + private volatile bool _recalculating; public void Start() { @@ -39,19 +41,25 @@ private void Init() private void ConsolidateStatisticsAsync() { - Task.Run(() => + if (_consolidating) return; + _consolidating = true; + _ = Task.Run(() => { try { ConsolidateStatistics(); } catch (Exception ex) { Logger.Exception(ex); } + finally { _consolidating = false; } }); } private void RecalculatePricesAndRenewOrdersAsync() { - Task.Run(() => + if (_recalculating) return; + _recalculating = true; + _ = Task.Run(() => { try { RecalculatePricesAndRenewOrders(); } catch (Exception ex) { Logger.Exception(ex); } + finally { _recalculating = false; } }); } From ae9ac8c2b452fde0f5dda0e04ec3f7cf1ef51f35 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:53:16 +0500 Subject: [PATCH 30/58] docs: mark raw_material_prices as deprecated (IMPROVEMENT-030) --- docs/db_structure/database_schema_documentation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index bb247f1..977eb85 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -5615,6 +5615,8 @@ Stores the Discord channel ID and message ID of the currently pinned message per ## 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 From 3fd85e3d84705ec30e82d69daa79a1e6990c6d64 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 08:54:43 +0500 Subject: [PATCH 31/58] docs: mark IMPROVEMENT-030 as DONE --- docs/backlog/improvements.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index d509160..ff2dff7 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -447,7 +447,7 @@ The last pinned message ID can be stored in memory across restarts only if a res ## IMPROVEMENT-030 - AutoMarket Overhaul: NIC Injection Control, Dynamic Risk-Aware Pricing, and Performance Refactor -Status: TODO +Status: DONE Priority: HIGH Area: Economy / AutoMarket / Database @@ -486,6 +486,34 @@ Inflation continues unchecked while the AutoMarket runs. Raw material prices do - 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` From a63377967a38eae0eeb446c459ef049ad7fea4a3 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 12:30:04 +0500 Subject: [PATCH 32/58] fix: refactor v_all_production_costs CROSS JOIN, improve EventListenerService error logging, add IMPROVEMENT-030 migration - Replace correlated subquery with CROSS JOIN for max_scarcity_price in v_all_production_costs - Replace swallowed catch blocks with Logger.Error in EventListenerService - Add migration SQL for IMPROVEMENT-030 automarket overhaul - Add automarket overhaul plan doc - Sync SP file encodings Co-Authored-By: Claude Sonnet 4.6 --- .../20260528_improvement_030_automarket.sql | 58 + ...te_raw_material_prices.StoredProcedure.sql | Bin 5244 -> 10740 bytes ...efreshAutoMarketOrders.StoredProcedure.sql | Bin 21960 -> 23010 bytes .../views/v_all_production_costs.sql | 6 +- .../plans/2026-05-28-automarket-overhaul.md | 1312 +++++++++++++++++ .../EventServices/EventListenerService.cs | 16 +- 6 files changed, 1387 insertions(+), 5 deletions(-) create mode 100644 docs/db_structure/migrations/20260528_improvement_030_automarket.sql create mode 100644 docs/superpowers/plans/2026-05-28-automarket-overhaul.md 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/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql b/docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql index 871478828697183ee73cae56eba0693e63a05837..033278d0b2109fba37d26f01213bec3d3a950a1f 100644 GIT binary patch literal 10740 zcmds7SyLNF5T55$4aDG3sZ?r9K>{m5AS20`KcD3Lrnfc6 z?kp==*pzEatC`uEp1!;1`0w9WX5Vy7X8tfE(>E9PJuy=}Lo+sA^QZaTd}g-IuKj2E zvAOR(8NU2w{>IoNGcivADFeibIR~u1>6tzJpW_?LQ`^S5dKhyK*pE z(YlJ#Q%q*gi%OdKWVhGHZ_8#7;15fr1YBLJG+V z(z5}+u+B1{J(PA$3oUJMgZ-B+d%*MJ|Gc|5oYjSiE+D6=fol%*=Nd zia_#5u}t~3K;K3|$GYWO4;;F}*VNOVaY%dofW9rX?%R3qqP7jl2f*Y2?XEw++$-R+ zw~Tk+`AzUu$nL+4>m}schc@Jp>I67nSR3I82T{ipLv7tasf{|ZVcYNce}EsKhxlo~ zJn92tD_yk#sMLrRRajE}Sc(oX#n?NUB0m$O~-u8{Mms}4u=X6@FU~B(jB3xzb(pJaUMZ1`nB7bGAUCg+%D&KL zQ8}b@1-X{JAv3sR;7Q-AvZlTEfj1)p^JsFy!K%|QVsgLW=6awBRZ*rc-w|zWS*%7j z8mMkD(_{`h29A&znVFE~{E3qE^om>qJ~Hdl45UA0#l3VZ`-)i8>j=H^WSXBm{1vGeB^>~81V@rBmSZ2S}!{$Y6YxRBQqyB@w>{BeBoZB=sk0eEx; zE_I>3Ck^c+j0UmgF)KO&Jn=dF+G_|m_VY2698%RB{zvdzt!y3kr)w{_GHK>VwrzI) zY8Ej!?MjUDi|NWSH#7Wxf*M*$%KrS_1V$w8!-$SJ*(~BK1x9K=$E~V-PZrun?1|LY ztMu97%GIB0j!#=No`?GL7WzV7)9(yysb^cN_o&7R?#C8aC)aAo)wtJ5wJP2c567p) zW!gr}d^sDTV7|l|Z4DNJ;mPr6&8P zEYAl|W6DZHKO?g({th$4^JX>53HH*69w1inby$y9O z70+A*Wj-Fq#`!esdqCjhWbAg$Qd+)o{YrOxf!m7r&8)Q;V#bu8EzF_Qk)Q9oJQ+6j5 zW|-T{-h!U2s0T}}QDQxww=Vo$E(b|@UEVk^j%y3*s!G7gM|S4kcPBNqf*3Z$lc!Ky zvs0*_kz;bbAYAeDr)oY?YxwZAUB_IX0kfxON!Mr0&Gey;4u5?auAOzQ;PBUYUcXLS z1_i0T>lgOajwjE;QObim@=}xA87Ur98{CP-beCLf4 zy-UG1IjP<-o7UHMfF;jtwov2k6P~mCxcZz)NlkZ!WaJ`qR?{Blt}iSVExV7b9MRmT zdc5G{-rVB~&*}Bf2K!8rX>vE(_h>R!ZN1EkramyFgf~Q#&+ktab)0m(oWW$QuhU13 zhjrSceX6;VvVF&EDg6eSJ%?U(W@}X9R;~YTrBUW{6HLpa%F>Zo+T?8N6c)-83Z6z~ zu;Wv^Qj%TPiCs~Xl5cU(OKz*^Z5^I2@l1;A7T#pIw51+ec|Mim8!eC#KyEydcj5k% ze@0i{TaLM|r!(&EMBP5JuFBLhuDh2bG^>8hcbld;jg+SFyi8r#BDUpd)seCH^+(+n dnq$4LIyn%$V=6y}L}gu~cf;s&{kxm2{|`ll+UWoQ literal 5244 zcmcgwZExE)5dQ98ao?JxZITAqx^-(2)lyPzj^$bMilQh4R#FnJlBMb;Y5x75BSpQ~ zPVH<#3D~sBBk#H6y^#yoqK|o0L9=-e66(SdB_eEug2s#oMzSrLUaE3zO8 z>ms6$;pgnr;m0BhL=x1As3N}*w|5$6?|n0Q4ezZ@R_ba--n z@-sCIjz;8K9vM^D_NR-<#3g5{An@>C3&*oe-!`qOXP?>DT+fc^IU1RFOOeI_m35vc zcSm8Aie*SoX?8PvUc}`Wy3C3c=twMsYbimMQ4-%olqaH01u2-L5sl}T;aN1BJ7ddS z%%POs1RRm!j_9p*ZUeS%41Ma1oyF7xVEPUF>t!W+x+J=({N;o?jF-^Z>@D_gtiZ4`a+%Ssd##(VqmPs22^rD=SiXPAWUFg7#h z%;&vj{i!D!V9f0)J|~j89o)oJ^7BI+@OGvzSGU_BggPt#vJgQPXG?5)HgOCO{cC*o zx)t~*n`=$1G0*3s5UGD7lA58M&7BK$qOP)36knps53=QDe1)<1?<|~wUH}_ndfxqs zvI~C&!;_BbX2pl4pT<8JY7v!LT?CO2=7oT&7{X3S#W#rh1C_b~sra^nw9_#B+DP(i zK|z+LIRCz@k|?RVjv9aN*0dflIKKx-={Yk{zrZI{F-1kNjss?7Ypm*W>5p~fYogFh zhLjlxKKRrN+FxJX+MOiztGm24yfC_ieX_uQ5yA4a!dJ2k^h9(36sIfZIfyV&-#AP2 z8t%x!u+GZ};b0lvHo=Z#B8em+#B0*=x+GXHD>g5(uvUH-T#I{XV$5{f!dgz-4J^To~8 z`hhf-uMe*DuLLXxWx+bBwsTG4%P{^%9@lA?FV+{H&z%K3;@c1S(wuxYk&br1e{w42 z`+-b8-?v5Hu~(wVOr(sfIR5j)TPP6abqp068 zCB>D+R$u;ps?t1n?H>o*8;wntSAA>`Ih$3?O&dhtC0A_0ruChS&s_QqXm^9RzuUei_wUBw zW!OZGwx#Oq=Ye#6$BA{u*PuTfn~S8jMALGW_<`%Fa;`xdz!-<1wIIBW#UT4~_?O$W zYp-OUt^4hiMq|>mTkO6M)6JS%venHEmlG4 z%e8f7$Gizw7lt=}XE6?rHQh7@WLwOd7XW^aAgY&frOXR|__SLL8|PCis2KQs(g!y`Q)y;H}8Y=0Y*g~e%ek9wMy*`WozzGpkt$^y6Szq|0QD2c?f z&M8YmLJeA#VDalQUPAaGrpS^cUS82{ybQD3{@u;Q>w{1+6~ B&r|>a 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 6b64873bbf712cfd1d9b788146d259e230399972..5ed2973485e069b9cc4363374581b17449ae8d96 100644 GIT binary patch delta 815 zcmaKqxl0345XR@lgv5Xsk%+{_g`h-opiv7^!6Skx5+Mn}F02|bU=FjJXczn&rn9oQ zF>YyNXCvWhvE?yX5X8cZ{|0{ySo%GrfAyEZH)-2l%)dY>4bEgi=>f( zTNOTyj$n1%4iG6&36U~RR=h|RdD3VPS$U~#MK!7;s~|P=bc(Dh;`>y?Ux&YrTbMQ} zLtCVd;!>B5YO-+w(vo>&TXHoLf0C#WoT7IgG@WWaJD~j6bSMszJeYJjNm|ZQk@WMR zT14bPMoIzvj5d7u2c=cOphI|z6ydWz53damj#MyU8S)xh7!)FvAJdb(vf|>D!|Th? z4pe@ix3Y4`Dxh$vMf?eC9_11bQPDzSkQM=H_VRO*$OZ&Z-irUHl zs>2`Xc5SiE@SJnZVjk+yw3)fN;Tr%ssI2Mn0QfjQ#aC&&v(3*U=Hu(&1mCA-JG@FS z-YP$Dd-}VC&?}x_JhUvgh}oBV*22DX*q?^Ss&>Ej>{ewFfE^dse2V$*ev0;Y7jC|- lemI2R6tSH9o41in*oB{;Q?Fl6ZX^}Xrl-w7=0e!q^a(@dsqz2- delta 108 zcmaE~neoJGMyCJ&Hq2s@S71nE$Y&^GP+%xvNCnamUM@o-LmopiL+WN-W=oFEW&C%H zCU4=<*_`3@ft9gra$%t8WD`EA%_SZ#Tww8YK@KdF<@gLHYlWWz2~0j0B(Pa3;us?U DXaOVV diff --git a/docs/db_structure/views/v_all_production_costs.sql b/docs/db_structure/views/v_all_production_costs.sql index 3ed8ae1..8bb3b48 100644 --- a/docs/db_structure/views/v_all_production_costs.sql +++ b/docs/db_structure/views/v_all_production_costs.sql @@ -59,20 +59,22 @@ computed_costs AS ( SELECT ac.product, SUM( - ac.total_quantity * ISNULL(mp.unit_price, (SELECT price FROM max_scarcity_price)) + ac.total_quantity * ISNULL(mp.unit_price, msp.price) ) AS production_cost_nic FROM aggregated_costs ac LEFT JOIN latest_market_prices mp ON ac.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp GROUP BY ac.product ), raw_resources AS ( SELECT base.raw_material AS product, - ISNULL(mp.unit_price, (SELECT price FROM max_scarcity_price)) AS production_cost_nic + 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 diff --git a/docs/superpowers/plans/2026-05-28-automarket-overhaul.md b/docs/superpowers/plans/2026-05-28-automarket-overhaul.md new file mode 100644 index 0000000..cd7ee6a --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-automarket-overhaul.md @@ -0,0 +1,1312 @@ +# IMPROVEMENT-030 AutoMarket Overhaul 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:** Fix the AutoMarket NIC faucet, introduce zone-aware gather tracking, replace static raw material prices with a dynamic supply/demand + PvP-risk formula, and set-base the cursor SQL for performance. + +**Architecture:** Schema-first (tables → procs → views → C# → manager), so each layer can be validated before the next depends on it. SQL changes ship as both live ALTER statements and updated docs `.sql` files. No automated tests exist; each task ends with a manual validation query. + +**Tech Stack:** SQL Server (T-SQL), C# 12 / .NET 8, existing `Db.Query()` pattern, `TimerAction` / `IProcess` pattern. + +**Spec:** `docs/superpowers/specs/2026-05-27-automarket-overhaul-design.md` + +--- + +## File Map + +| File | Action | +|---|---| +| SQL Server (live DB) | CREATE TABLE `automarket_config`; ALTER TABLE `resources_gathered_daily`, `resources_gathered`; ALTER PROC ×3; ALTER VIEW ×1 | +| `docs/db_structure/database_schema_documentation.md` | Add `automarket_config` entry; add `is_pvp` column to two tables | +| `docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql` | Add `@is_pvp` param | +| `docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql` | Add `is_pvp` to GROUP BY and MERGE | +| `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` | Complete rewrite | +| `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` | Budget cap + set-based inserts | +| `docs/db_structure/views/v_all_production_costs.sql` | Remove `raw_material_prices` fallback | +| `src/Perpetuum/Modules/DrillerModule.cs` | Add `@is_pvp` at line 210 | +| `src/Perpetuum/Modules/HarvesterModule.cs` | Add `@is_pvp` at line 160 | +| `src/Perpetuum/Modules/LargeDrillerModule.cs` | Add `@is_pvp` at line 131 | +| `src/Perpetuum/Modules/LargeHarvesterModule.cs` | Add `@is_pvp` at line 102 | +| `src/Perpetuum/Services/Looting/LootContainer.cs` | Add `@is_pvp` at line 637 | +| `src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs` | Change interval; evaluate async wrapping | + +--- + +## Task 1: Create `automarket_config` table + +**Files:** +- Live DB: new table +- `docs/db_structure/database_schema_documentation.md`: new entry + +- [ ] **Step 1.1: Execute schema DDL in SQL Server Management Studio** + +```sql +IF OBJECT_ID('dbo.automarket_config', 'U') IS NULL +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) + ); + + INSERT INTO dbo.automarket_config (param_name, param_value) 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); +END; +``` + +- [ ] **Step 1.2: Verify table and data** + +```sql +SELECT param_name, param_value FROM automarket_config ORDER BY param_name; +-- Expected: 5 rows matching the values above +``` + +- [ ] **Step 1.3: Add entry to `docs/db_structure/database_schema_documentation.md`** + +Find the alphabetically correct position (between `automarket_unbought_resources` and the next table). Add: + +```markdown +## 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 | +|---|---| +| `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` | + +--- +``` + +- [ ] **Step 1.4: Commit** + +```bash +git add "docs/db_structure/database_schema_documentation.md" +git commit -m "feat(db): add automarket_config table (IMPROVEMENT-030)" +``` + +--- + +## Task 2: Add `is_pvp` to gather tables + +**Files:** +- Live DB: ALTER TABLE × 2 +- `docs/db_structure/database_schema_documentation.md`: update two table entries + +- [ ] **Step 2.1: Execute DDL in SSMS** + +```sql +-- resources_gathered_daily +IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.resources_gathered_daily') AND name = 'is_pvp' +) + ALTER TABLE dbo.resources_gathered_daily + ADD is_pvp BIT NOT NULL DEFAULT 0; + +-- resources_gathered (summary table) +IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.resources_gathered') AND name = 'is_pvp' +) + ALTER TABLE dbo.resources_gathered + ADD is_pvp BIT NOT NULL DEFAULT 0; +``` + +- [ ] **Step 2.2: Verify columns exist** + +```sql +SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME IN ('resources_gathered_daily', 'resources_gathered') + AND COLUMN_NAME = 'is_pvp'; +-- Expected: 2 rows, both BIT, NOT NULL, DEFAULT 0 +``` + +- [ ] **Step 2.3: Verify existing rows got default value** + +```sql +SELECT COUNT(*) AS total, SUM(CAST(is_pvp AS INT)) AS pvp_count +FROM resources_gathered; +-- Expected: pvp_count = 0 (all historical rows treated as PvE) +``` + +- [ ] **Step 2.4: Update schema documentation** + +In `docs/db_structure/database_schema_documentation.md`, in the `resources_gathered_daily` and `resources_gathered` table entries, add: + +```markdown +| `is_pvp` | `bit [not null, default: 0]` | +``` + +- [ ] **Step 2.5: Commit** + +```bash +git add "docs/db_structure/database_schema_documentation.md" +git commit -m "feat(db): add is_pvp column to resources_gathered tables (IMPROVEMENT-030)" +``` + +--- + +## Task 3: Alter `sp_RecordResourceGathered` + +**Files:** +- Live DB: ALTER PROCEDURE +- `docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql` + +- [ ] **Step 3.1: ALTER the procedure in SSMS** + +```sql +ALTER PROCEDURE [dbo].[sp_RecordResourceGathered] + @gathered_on DATE, + @resource_name VARCHAR(100), + @quantity BIGINT, + @is_pvp BIT = 0 +AS +BEGIN + SET NOCOUNT ON; + INSERT INTO resources_gathered_daily (gathered_on, resource_name, quantity, is_pvp) + VALUES (@gathered_on, @resource_name, @quantity, @is_pvp); +END; +``` + +- [ ] **Step 3.2: Verify the parameter is accepted and backward-compatible** + +```sql +-- Omitting @is_pvp (backward compat, default = 0) +EXEC sp_RecordResourceGathered @gathered_on = '2026-01-01', @resource_name = 'test_material', @quantity = 1; + +-- With @is_pvp = 1 (PvP gather) +EXEC sp_RecordResourceGathered @gathered_on = '2026-01-01', @resource_name = 'test_material', @quantity = 2, @is_pvp = 1; + +SELECT * FROM resources_gathered_daily WHERE resource_name = 'test_material'; +-- Expected: 2 rows — one with is_pvp=0, one with is_pvp=1 + +-- Clean up test rows +DELETE FROM resources_gathered_daily WHERE resource_name = 'test_material'; +``` + +- [ ] **Step 3.3: Update the doc file** + +Replace the full content of `docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql` with: + +```sql +USE [perpetuumsa] +GO +/****** Object: StoredProcedure [dbo].[sp_RecordResourceGathered] Script Date: 28.05.2026 ******/ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +---- Register gathered resource quantity with optional PvP zone flag + +CREATE PROCEDURE [dbo].[sp_RecordResourceGathered] + @gathered_on DATE, + @resource_name VARCHAR(100), + @quantity BIGINT, + @is_pvp BIT = 0 +AS +BEGIN + SET NOCOUNT ON; + INSERT INTO resources_gathered_daily (gathered_on, resource_name, quantity, is_pvp) + VALUES (@gathered_on, @resource_name, @quantity, @is_pvp); +END; +GO +``` + +- [ ] **Step 3.4: Commit** + +```bash +git add "docs/db_structure/stored_procedures/dbo.sp_RecordResourceGathered.StoredProcedure.sql" +git commit -m "feat(db): add @is_pvp param to sp_RecordResourceGathered (IMPROVEMENT-030)" +``` + +--- + +## Task 4: Alter `consolidate_statistics` + +**Files:** +- Live DB: ALTER PROCEDURE +- `docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql` + +- [ ] **Step 4.1: ALTER the procedure in SSMS** + +Only the resources block changes. The plasma block is unchanged. + +```sql +ALTER PROCEDURE [dbo].[consolidate_statistics] +AS +BEGIN + SET NOCOUNT ON; + + -- Resources block: aggregate daily buffer into summary, tracking is_pvp + WITH Aggregated AS ( + SELECT + gathered_on, + resource_name, + is_pvp, + SUM(quantity) AS total_quantity + FROM resources_gathered_daily WITH (READPAST) + GROUP BY gathered_on, resource_name, is_pvp + ) + MERGE INTO resources_gathered AS target + USING Aggregated AS source + ON target.gathered_on = source.gathered_on + AND target.resource_name = source.resource_name + AND target.is_pvp = source.is_pvp + WHEN MATCHED THEN + UPDATE SET quantity = target.quantity + source.total_quantity + WHEN NOT MATCHED THEN + INSERT (gathered_on, resource_name, quantity, is_pvp) + VALUES (source.gathered_on, source.resource_name, source.total_quantity, source.is_pvp); + + DELETE FROM resources_gathered_daily; + + -- Plasma block: unchanged + WITH Aggregated AS ( + SELECT + gathered_on, + plasma_type, + SUM(quantity) AS total_quantity + FROM plasma_gathered_daily WITH (READPAST) + GROUP BY gathered_on, plasma_type + ) + MERGE INTO plasma_gathered AS target + USING Aggregated AS source + ON target.gathered_on = source.gathered_on + AND target.plasma_type = source.plasma_type + WHEN MATCHED THEN + UPDATE SET quantity = target.quantity + source.total_quantity + WHEN NOT MATCHED THEN + INSERT (gathered_on, plasma_type, quantity) + VALUES (source.gathered_on, source.plasma_type, source.total_quantity); + + DELETE FROM plasma_gathered_daily; +END; +GO +``` + +- [ ] **Step 4.2: Verify the MERGE key change** + +Seed test rows with both PvP and PvE for the same resource on the same day: + +```sql +INSERT INTO resources_gathered_daily (gathered_on, resource_name, quantity, is_pvp) +VALUES ('2026-01-02', 'test_ore', 100, 0), + ('2026-01-02', 'test_ore', 200, 1); + +EXEC consolidate_statistics; + +SELECT gathered_on, resource_name, quantity, is_pvp +FROM resources_gathered WHERE resource_name = 'test_ore'; +-- Expected: 2 rows — quantity=100 is_pvp=0, quantity=200 is_pvp=1 + +-- Clean up +DELETE FROM resources_gathered WHERE resource_name = 'test_ore'; +``` + +- [ ] **Step 4.3: Update the doc file** + +Replace the full content of `docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql` with the procedure text from Step 4.1 (wrapped in `USE [perpetuumsa] GO` header as the existing file uses). + +- [ ] **Step 4.4: Commit** + +```bash +git add "docs/db_structure/stored_procedures/dbo.consolidate_statistics.StoredProcedure.sql" +git commit -m "feat(db): include is_pvp in consolidate_statistics MERGE key (IMPROVEMENT-030)" +``` + +--- + +## Task 5: Update C# gather call sites to pass `@is_pvp` + +**Files:** +- `src/Perpetuum/Modules/DrillerModule.cs` (line 210) +- `src/Perpetuum/Modules/HarvesterModule.cs` (line 160) +- `src/Perpetuum/Modules/LargeDrillerModule.cs` (line 131) +- `src/Perpetuum/Modules/LargeHarvesterModule.cs` (line 102) +- `src/Perpetuum/Services/Looting/LootContainer.cs` (line 637) + +`zone.Configuration.Protected == true` means alpha (PvE zone) → `@is_pvp = false`. +`zone.Configuration.Protected == false` means beta/gamma (PvP zone) → `@is_pvp = true`. + +- [ ] **Step 5.1: Update `DrillerModule.cs` (lines 209–214)** + +Old: +```csharp +Db.Query() + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .SetParameter("@gathered_on", DateTime.UtcNow) + .SetParameter("@resource_name", resourceName) + .SetParameter("@quantity", quantity) + .ExecuteNonQuery(); +``` + +New: +```csharp +Db.Query() + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") + .SetParameter("@gathered_on", DateTime.UtcNow) + .SetParameter("@resource_name", resourceName) + .SetParameter("@quantity", quantity) + .SetParameter("@is_pvp", !zone.Configuration.Protected) + .ExecuteNonQuery(); +``` + +- [ ] **Step 5.2: Update `HarvesterModule.cs` (lines 159–164)** + +Apply the identical change as Step 5.1 — same pattern, same `zone` variable in scope. + +- [ ] **Step 5.3: Update `LargeDrillerModule.cs` (lines 130–135)** + +Apply the identical change as Step 5.1. + +- [ ] **Step 5.4: Update `LargeHarvesterModule.cs` (lines 101–106)** + +Apply the identical change as Step 5.1. + +- [ ] **Step 5.5: Update `LootContainer.cs` (lines 636–641)** + +The `zone` parameter is in scope at this location — the enclosing method is `Build(IZone zone, Position position)`. + +Old: +```csharp +Db.Query() + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity") + .SetParameter("@gathered_on", DateTime.UtcNow) + .SetParameter("@resource_name", fragment.Key) + .SetParameter("@quantity", fragment.Sum(x => x.ItemInfo.Quantity)) + .ExecuteNonQuery(); +``` + +New: +```csharp +Db.Query() + .CommandText("exec sp_RecordResourceGathered @gathered_on, @resource_name, @quantity, @is_pvp") + .SetParameter("@gathered_on", DateTime.UtcNow) + .SetParameter("@resource_name", fragment.Key) + .SetParameter("@quantity", fragment.Sum(x => x.ItemInfo.Quantity)) + .SetParameter("@is_pvp", !zone.Configuration.Protected) + .ExecuteNonQuery(); +``` + +- [ ] **Step 5.6: Build to confirm no compilation errors** + +```bash +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: Build succeeded, 0 errors, 0 warnings in modified files. + +- [ ] **Step 5.7: Commit** + +```bash +git add src/Perpetuum/Modules/DrillerModule.cs +git add src/Perpetuum/Modules/HarvesterModule.cs +git add src/Perpetuum/Modules/LargeDrillerModule.cs +git add src/Perpetuum/Modules/LargeHarvesterModule.cs +git add src/Perpetuum/Services/Looting/LootContainer.cs +git commit -m "feat: pass @is_pvp to sp_RecordResourceGathered from all gather modules (IMPROVEMENT-030)" +``` + +--- + +## Task 6: Rewrite `recalculate_raw_material_prices` + +**Files:** +- Live DB: ALTER PROCEDURE +- `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` + +**New formula:** +``` +plasma_anchor = alpha_common_plasma_price × plasma_anchor_fraction +supply_daily_avg = SUM(resources_gathered.quantity WHERE last 7 days) / 7.0 +demand_daily_avg = SUM(v_required_raw_materials.total_quantity) / 7.0 +ds_ratio = CLAMP(ds_ratio_min, ds_ratio_max, demand / supply_daily_avg) +pvp_ratio = pvp_qty / total_qty (NULL if no gather data) +risk = 1.0 + ISNULL(pvp_ratio, 1.0) +price = ROUND(plasma_anchor × ds_ratio × risk, 2) +``` + +Zero or no gather data → `ds_ratio` hits ceiling (4.0), `risk` = 2.0 → max-scarcity price (correct). + +- [ ] **Step 6.1: ALTER the procedure in SSMS** + +```sql +ALTER PROCEDURE [dbo].[recalculate_raw_material_prices] +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @today DATE = CAST(GETUTCDATE() AS DATE); + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, @today) + 2, @today); + DECLARE @start_date DATE = DATEADD(DAY, -7, @today); + + DECLARE @anchor_fraction FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'plasma_anchor_fraction' + ); + DECLARE @ds_min FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_min' + ); + DECLARE @ds_max FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_max' + ); + + -- Alpha common plasma price as the anchor + DECLARE @plasma_anchor FLOAT = ( + SELECT TOP 1 dynamic_price + FROM fn_CalculateDynamicPlasmaPrices(1) + WHERE plasma_type = 'def_common_reactor_plasma' + ) * @anchor_fraction; + + -- Compute and upsert new prices for all raw materials in the production chain + WITH + supply AS ( + SELECT + resource_name, + SUM(CASE WHEN is_pvp = 0 THEN quantity ELSE 0 END) AS pve_qty, + SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END) AS pvp_qty, + SUM(quantity) AS total_qty, + SUM(quantity) / 7.0 AS supply_daily_avg + FROM resources_gathered + WHERE gathered_on >= @start_date + GROUP BY resource_name + ), + 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 + ), + priced AS ( + SELECT + m.resource_name, + ROUND( + @plasma_anchor + * CASE + WHEN s.supply_daily_avg IS NULL OR s.supply_daily_avg = 0 + THEN @ds_max + ELSE + CASE + WHEN d.daily_demand / s.supply_daily_avg < @ds_min THEN @ds_min + WHEN d.daily_demand / s.supply_daily_avg > @ds_max THEN @ds_max + ELSE d.daily_demand / s.supply_daily_avg + END + END + * (1.0 + ISNULL( + CAST(s.pvp_qty AS FLOAT) / NULLIF(s.total_qty, 0), + 1.0 + )), + 2 + ) AS new_price + FROM materials m + LEFT JOIN supply s ON s.resource_name = m.resource_name + LEFT JOIN demand_cte d ON d.raw_material = m.resource_name + ) + MERGE INTO dbo.resource_market_prices AS target + USING priced AS source + ON target.calculated_on = @week_start + AND target.resource_name COLLATE DATABASE_DEFAULT = source.resource_name COLLATE DATABASE_DEFAULT + WHEN MATCHED THEN + UPDATE SET unit_price = source.new_price + WHEN NOT MATCHED THEN + INSERT (calculated_on, resource_name, unit_price) + VALUES (@week_start, source.resource_name, source.new_price); + + -- Cleanup old stats (90-day rolling window) + DELETE FROM plasma_gathered WHERE gathered_on < DATEADD(DAY, -90, @today); + DELETE FROM plasma_sold WHERE sold_on < DATEADD(DAY, -90, @today); + DELETE FROM resources_gathered WHERE gathered_on < DATEADD(DAY, -90, @today); +END; +GO +``` + +- [ ] **Step 6.2: Verify output manually** + +```sql +EXEC recalculate_raw_material_prices; + +-- All materials in the production chain should have a price +SELECT r.resource_name, r.unit_price +FROM resource_market_prices r +WHERE r.calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) +ORDER BY r.unit_price DESC; +-- Expected: one row per raw material in v_required_raw_materials +-- No NULL prices, no zero prices + +-- Confirm formula range: prices should be between anchor×0.25×1 and anchor×4×2 +-- With alpha common plasma at its current dynamic price × 0.15 = anchor +-- Min possible: anchor × 0.25 × 1.0 +-- Max possible: anchor × 4.0 × 2.0 +DECLARE @anchor FLOAT = ( + SELECT TOP 1 dynamic_price * 0.15 + FROM fn_CalculateDynamicPlasmaPrices(1) + WHERE plasma_type = 'def_common_reactor_plasma' +); +SELECT @anchor * 0.25 AS min_price, @anchor * 8.0 AS max_price; +-- Verify the prices in resource_market_prices fall within this range +``` + +- [ ] **Step 6.3: Confirm PvP materials price higher than equivalent PvE** + +If the DB has gather history with `is_pvp` data, run: + +```sql +SELECT resource_name, unit_price +FROM resource_market_prices +WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) +ORDER BY unit_price DESC; +-- Materials exclusively from PvP zones should appear higher than PvE equivalents +-- with matching supply/demand ratios (price = anchor × ds_ratio × 2.0 vs × 1.0) +``` + +If no `is_pvp=1` gather history exists yet, confirm materials with no gather data at all get the max-scarcity price (~anchor × 8.0). + +- [ ] **Step 6.4: Update the doc file** + +Replace the full content of `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` with the procedure text from Step 6.1 (wrapped in standard `USE [perpetuumsa] GO` header). + +- [ ] **Step 6.5: Commit** + +```bash +git add "docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql" +git commit -m "feat(db): rewrite recalculate_raw_material_prices with supply/demand + PvP-risk formula (IMPROVEMENT-030)" +``` + +--- + +## Task 7: Alter `v_all_production_costs` + +**Files:** +- Live DB: ALTER VIEW +- `docs/db_structure/views/v_all_production_costs.sql` + +Remove the `raw_material_prices` dependency. The `raw_resources` CTE switches from reading `raw_material_prices` for both enumeration and fallback price to reading from `v_required_raw_materials` + `resource_market_prices`. An inline max-scarcity fallback replaces `base.price_nic`. + +- [ ] **Step 7.1: Execute ALTER VIEW in SSMS** + +```sql +ALTER VIEW [dbo].[v_all_production_costs] AS +WITH all_items AS ( + SELECT product AS item FROM production_data + UNION + SELECT components AS item FROM production_data +), +recursive_materials AS ( + SELECT + base.item, + pd.components AS raw_material, + CAST(pd.amount * 2.1 AS FLOAT) AS quantity + FROM all_items base + JOIN production_data pd ON pd.product = base.item + + UNION ALL + + SELECT + rm.item, + pd.components AS raw_material, + rm.quantity * pd.amount * 2.1 AS quantity + FROM recursive_materials rm + JOIN production_data pd ON rm.raw_material = pd.product +), +aggregated_costs AS ( + SELECT + rm.item AS product, + rm.raw_material, + SUM(rm.quantity) AS total_quantity + FROM recursive_materials rm + GROUP BY rm.item, rm.raw_material +), +latest_market_prices AS ( + SELECT rmp.resource_name, rmp.unit_price + FROM resource_market_prices rmp + WHERE rmp.calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) +), +-- Inline max-scarcity fallback: plasma_anchor × ds_ratio_max × 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, (SELECT price FROM max_scarcity_price)) + ) AS production_cost_nic + FROM aggregated_costs ac + LEFT JOIN latest_market_prices mp + ON ac.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + GROUP BY ac.product +), +raw_resources AS ( + SELECT + base.raw_material AS product, + ISNULL(mp.unit_price, (SELECT price FROM max_scarcity_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 +), +final_costs AS ( + SELECT * FROM computed_costs + UNION + SELECT * FROM raw_resources +) +SELECT + product, + ROUND(production_cost_nic, 2) AS production_cost_nic +FROM final_costs; +GO +``` + +- [ ] **Step 7.2: Verify the view returns data** + +```sql +SELECT TOP 20 product, production_cost_nic +FROM v_all_production_costs +ORDER BY production_cost_nic DESC; +-- Expected: no NULL production_cost_nic values +-- Prices should be positive and in a plausible range +``` + +- [ ] **Step 7.3: Confirm no raw_material_prices dependency** + +```sql +-- Check the view definition no longer references raw_material_prices +SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.v_all_production_costs')); +-- Expected: 'raw_material_prices' does NOT appear in the output +``` + +- [ ] **Step 7.4: Update the doc file** + +Replace the full content of `docs/db_structure/views/v_all_production_costs.sql` with the view text from Step 7.1 (wrapped in standard header with `SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO`). + +- [ ] **Step 7.5: Commit** + +```bash +git add "docs/db_structure/views/v_all_production_costs.sql" +git commit -m "feat(db): remove raw_material_prices dependency from v_all_production_costs (IMPROVEMENT-030)" +``` + +--- + +## Task 8: Rewrite `usp_RefreshAutoMarketOrders` + +**Files:** +- Live DB: ALTER PROCEDURE +- `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` + +**Changes:** +1. Before plasma buy order inserts: read budget params and compute remaining daily budget. +2. Replace three cursor loops (alpha/beta/gamma) with set-based INSERTs that apply fractional quantity and budget cap. +3. Replace raw material buy order cursor (Step 4) with set-based INSERT. +4. Replace raw resource sell order cursor (Step 5) with set-based INSERT. + +**Budget semantics:** `@remaining_budget` is computed once at the start using `plasma_sold.income` for today. Each individual plasma order's quantity is capped independently to `@remaining_budget / unit_price`. This is equivalent to the cursor approach (which also never decremented `@remaining_budget` inside the loop). + +- [ ] **Step 8.1: Execute ALTER PROCEDURE in SSMS** + +```sql +ALTER PROCEDURE [dbo].[usp_RefreshAutoMarketOrders] +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRY + 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 excluding plasma (definitions 3271-3274) + INSERT INTO automarket_unbought_resources (itemdefinition, quantity) + SELECT itemdefinition, SUM(CAST(quantity AS BIGINT)) + FROM marketitems + WHERE isAutoOrder = 1 AND isSell = 0 + AND itemdefinition NOT IN (3271, 3272, 3273, 3274) + GROUP BY itemdefinition; + + -- Step 1: Remove old auto orders + DELETE FROM marketitems WHERE isAutoOrder = 1; + + -- Budget params for plasma buy orders + 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; + + -- 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 (unchanged, already set-based) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + ed.definition, + @vendoreid, + 0, + 1, + pc.production_cost_nic, + moc.amount, + 1, + 1 + FROM market_orders_configuration moc + INNER JOIN entitydefaults ed ON moc.definitionname = ed.definitionname + INNER JOIN v_all_production_costs pc ON moc.definitionname = pc.product; + + -- Step 4: Raw material buy orders (set-based, replaces cursor) + ;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 v_required_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 + ) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + c.combined_def, + @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 v_all_production_costs apc ON ed.definitionname = apc.product + WHERE c.total_required_quantity > 0; + + -- Step 5: Raw resource sell orders (set-based, replaces cursor) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + ed.definition, + @vendoreid, + 0, + 1, + apc.production_cost_nic * 2.0, + 10000000, + 1, + 1 + FROM v_required_raw_materials rrm + INNER JOIN entitydefaults ed ON rrm.raw_material = ed.definitionname + INNER JOIN v_all_production_costs apc ON rrm.raw_material = apc.product + GROUP BY ed.definition, apc.production_cost_nic; + + END TRY + BEGIN CATCH + PRINT 'Error in usp_RefreshAutoMarketOrders: ' + ERROR_MESSAGE(); + THROW; + END CATCH +END; +GO +``` + +- [ ] **Step 8.2: Test the procedure manually** + +```sql +-- First, check current plasma budget spent today +SELECT sold_on, SUM(income) AS total_income +FROM plasma_sold +WHERE sold_on = CAST(GETUTCDATE() AS DATE) +GROUP BY sold_on; + +-- Execute the refresh +EXEC usp_RefreshAutoMarketOrders; + +-- Verify plasma buy orders were placed with reduced quantity +SELECT mi.itemdefinition, ed.definitionname, mi.price, mi.quantity, mi.isSell +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND ed.definitionname LIKE '%plasma%' +ORDER BY mi.marketeid, ed.definitionname; +-- Expected: plasma buy orders with quantity ≤ 60% of what the old procedure placed + +-- Verify no NULL prices in auto orders +SELECT COUNT(*) FROM marketitems WHERE isAutoOrder = 1 AND price IS NULL; +-- Expected: 0 + +-- Verify raw material buy orders exist +SELECT COUNT(*) FROM marketitems WHERE isAutoOrder = 1 AND isSell = 0; +-- Expected: > 0 +``` + +- [ ] **Step 8.3: Test budget cap** + +```sql +-- Temporarily set a tiny budget to confirm orders are skipped +UPDATE automarket_config SET param_value = 1 WHERE param_name = 'daily_plasma_budget_nic'; + +EXEC usp_RefreshAutoMarketOrders; + +SELECT COUNT(*) AS plasma_buy_orders +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND ed.definitionname LIKE '%plasma%'; +-- Expected: 0 (budget is 1 NIC, prices are hundreds of NIC — nothing fits) + +-- Restore the budget +UPDATE automarket_config SET param_value = 500000 WHERE param_name = 'daily_plasma_budget_nic'; +EXEC usp_RefreshAutoMarketOrders; +``` + +- [ ] **Step 8.4: Update the doc file** + +Replace the full content of `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` with the procedure text from Step 8.1 (wrapped in standard `USE [perpetuumsa] GO` header). + +- [ ] **Step 8.5: Commit** + +```bash +git add "docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql" +git commit -m "feat(db): add budget cap, fractional qty, set-based inserts to usp_RefreshAutoMarketOrders (IMPROVEMENT-030)" +``` + +--- + +## Task 9: Update `MarketAutoOrdersManager` — interval and thread-safety + +**Files:** +- `src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs` + +### Part A — Change refresh interval + +- [ ] **Step 9.1: Change `TimeSpan.FromDays(3)` to `TimeSpan.FromDays(1)`** + +In `MarketAutoOrdersManager.cs`, in the `Init()` method, line 30: + +Old: +```csharp +_timers.Add(new TimerAction(RecalculatePricesAndRenewOrders, TimeSpan.FromDays(3))); +``` + +New: +```csharp +_timers.Add(new TimerAction(RecalculatePricesAndRenewOrders, TimeSpan.FromDays(1))); +``` + +### Part B — Analyze and address thread-safety + +- [ ] **Step 9.2: Read `TimerAction` and `TimerList` implementations** + +Locate and read: + +```bash +# Find the timer implementations +grep -r "class TimerAction" src/ --include="*.cs" -l +grep -r "class TimerList" src/ --include="*.cs" -l +``` + +Confirm: does `TimerList.Update(TimeSpan)` fire callbacks synchronously on the calling thread? + +- [ ] **Step 9.3: Determine which thread drives `IProcess.Update`** + +Read `ProcessManager` (or equivalent class that calls `.Update(time)` on registered processes): + +```bash +grep -r "IProcess" src/ --include="*.cs" -l +grep -r "MarketAutoOrdersManager" src/ --include="*.cs" -l +``` + +Determine: is `Update(time)` called from the main server process loop (same thread as zone updates)? + +- [ ] **Step 9.4: Apply async wrapping if warranted** + +**Decision criteria:** If timer callbacks fire synchronously on the main process loop thread AND `RecalculatePricesAndRenewOrders` takes > 200 ms (estimated from: delete all auto orders + run price calc + re-insert all orders), wrap in `Task.Run` with exception logging. + +If wrapping is warranted, apply this pattern to `RecalculatePricesAndRenewOrders` and `ConsolidateStatistics`: + +```csharp +private void Init() +{ + _timers.Add(new TimerAction(ConsolidateStatisticsAsync, TimeSpan.FromMinutes(15))); + _timers.Add(new TimerAction(RecalculatePricesAndRenewOrdersAsync, TimeSpan.FromDays(1))); +} + +private void ConsolidateStatisticsAsync() +{ + Task.Run(() => + { + try { ConsolidateStatistics(); } + catch (Exception ex) { Logger.Error($"ConsolidateStatistics failed: {ex.Message}"); } + }); +} + +private void RecalculatePricesAndRenewOrdersAsync() +{ + Task.Run(() => + { + try { RecalculatePricesAndRenewOrders(); } + catch (Exception ex) { Logger.Error($"RecalculatePricesAndRenewOrders failed: {ex.Message}"); } + }); +} + +private void ConsolidateStatistics() { /* unchanged */ } +private void RecalculatePricesAndRenewOrders() { /* unchanged */ } +``` + +**Note:** `Logger` usage — find the Logger import in the file or a sibling class in the `MarketEngine` namespace and use the same pattern. If `Logger` is not available, use `System.Diagnostics.Debug.WriteLine` as a fallback and note it as technical debt. + +If the analysis shows `Update` is NOT on the main process loop or the operations are fast enough, document the finding in a code comment and leave the synchronous approach. Either outcome must be committed with a note. + +- [ ] **Step 9.5: Build** + +```bash +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors. + +- [ ] **Step 9.6: Commit** + +```bash +git add src/Perpetuum/Services/MarketEngine/MarketAutoOrdersManager.cs +git commit -m "feat: change AutoMarket refresh interval to 1 day; wrap async if process-loop (IMPROVEMENT-030)" +``` + +--- + +## Task 10: Update schema documentation + +**Files:** +- `docs/db_structure/database_schema_documentation.md` + +This task ensures the schema docs reflect all changes from Tasks 1–9 that haven't already been committed. + +- [ ] **Step 10.1: Verify all four doc-file updates were committed** + +Check that the following commits exist in git log: +- `automarket_config` table entry added +- `is_pvp` column added to `resources_gathered_daily` and `resources_gathered` entries +- `sp_RecordResourceGathered.StoredProcedure.sql` updated +- `consolidate_statistics.StoredProcedure.sql` updated +- `recalculate_raw_material_prices.StoredProcedure.sql` updated +- `usp_RefreshAutoMarketOrders.StoredProcedure.sql` updated +- `v_all_production_costs.sql` updated + +```bash +git log --oneline | head -20 +``` + +- [ ] **Step 10.2: Verify `raw_material_prices` is documented as deprecated** + +In `docs/db_structure/database_schema_documentation.md`, find the `raw_material_prices` entry and add a deprecation note: + +```markdown +> **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. +``` + +- [ ] **Step 10.3: Commit** + +```bash +git add "docs/db_structure/database_schema_documentation.md" +git commit -m "docs: mark raw_material_prices as deprecated (IMPROVEMENT-030)" +``` + +--- + +## Task 11: End-to-end manual validation + +All changes deployed. Run the full validation sequence from the spec. + +- [ ] **Step 11.1: Confirm `automarket_config` is present and readable** + +```sql +SELECT * FROM automarket_config; +-- Expected: 5 rows with correct values +``` + +- [ ] **Step 11.2: Run full market refresh and confirm plasma buy orders** + +```sql +EXEC usp_RefreshAutoMarketOrders; + +SELECT mi.marketeid, ed.definitionname, mi.price, mi.quantity +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND ed.definitionname LIKE '%plasma%' +ORDER BY mi.marketeid; + +-- Verify: quantity for each order ≤ 60% of the last-7-day gathered for that plasma type +-- Cross-reference against: +SELECT plasma_type, SUM(quantity) AS gathered_7d +FROM plasma_gathered +WHERE gathered_on >= DATEADD(DAY, -7, CAST(GETUTCDATE() AS DATE)) +GROUP BY plasma_type; +``` + +- [ ] **Step 11.3: Validate gather tracking captures PvP flag** + +Requires a running server. Gather a small amount of resources in an alpha zone (PvE) and a beta zone (PvP). Wait ≤ 15 minutes for `consolidate_statistics` to run. Then: + +```sql +SELECT gathered_on, resource_name, quantity, is_pvp +FROM resources_gathered +WHERE gathered_on = CAST(GETUTCDATE() AS DATE) +ORDER BY resource_name, is_pvp; +-- Expected: same resource appears with is_pvp=0 (alpha) and is_pvp=1 (beta) +``` + +- [ ] **Step 11.4: Validate dynamic raw material prices** + +```sql +EXEC recalculate_raw_material_prices; + +SELECT r.resource_name, r.unit_price +FROM resource_market_prices r +WHERE r.calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) +ORDER BY r.unit_price DESC; +-- Expected: all materials in v_required_raw_materials have a price +-- No NULL, no zero prices +``` + +- [ ] **Step 11.5: Validate `v_all_production_costs` — no NULL production costs** + +```sql +SELECT COUNT(*) FROM v_all_production_costs WHERE production_cost_nic IS NULL; +-- Expected: 0 + +SELECT COUNT(*) FROM v_all_production_costs WHERE production_cost_nic <= 0; +-- Expected: 0 +``` + +- [ ] **Step 11.6: Validate item sell prices are in a reasonable range** + +After `usp_RefreshAutoMarketOrders` runs, check auto sell order prices: + +```sql +SELECT mi.price, ed.definitionname +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 1 +ORDER BY mi.price DESC; +-- Manually confirm prices look plausible (not 100× off from expected) +``` + +- [ ] **Step 11.7: Final build check** + +```bash +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors, 0 warnings in any modified file. + +- [ ] **Step 11.8: Update IMPROVEMENT-030 in backlog** + +In `docs/backlog/improvements.md`, change the IMPROVEMENT-030 entry status from `IN_PROGRESS` to `DONE`. + +- [ ] **Step 11.9: Commit backlog update** + +```bash +git add "docs/backlog/improvements.md" +git commit -m "docs: mark IMPROVEMENT-030 as DONE" +``` + +--- + +## Regression Checklist + +Before considering IMPROVEMENT-030 complete, verify: + +| Risk | Check | +|---|---| +| Client market order visibility | Auto orders re-insert on refresh; no client IDs are persisted → no regression | +| Production cost calculations | Run Step 11.5 and 11.6 to confirm `v_all_production_costs` has no NULLs and sell prices are sane | +| `consolidate_statistics` key change | Historical rows default to `is_pvp = 0` (PvE) — correct; no data loss | +| Modules missing `@is_pvp` | Verify all 5 call sites were updated in Task 5; stored proc defaults to `0` (PvE) if missed | +| Daily budget exhaustion | Step 8.3 confirms zero plasma orders when budget is 0 | +| `v_all_production_costs` `raw_material_prices` removal | Step 7.3 confirms no reference remains | diff --git a/src/Perpetuum/Services/EventServices/EventListenerService.cs b/src/Perpetuum/Services/EventServices/EventListenerService.cs index 3b20ee4..4a9e17e 100644 --- a/src/Perpetuum/Services/EventServices/EventListenerService.cs +++ b/src/Perpetuum/Services/EventServices/EventListenerService.cs @@ -1,5 +1,6 @@ using Discord; using Discord.WebSocket; +using Perpetuum.Log; using Perpetuum.Services.EventServices.EventMessages; using Perpetuum.Services.EventServices.EventProcessors; using Perpetuum.Threading.Process; @@ -77,18 +78,27 @@ public void PublishMessage(IEventMessage message) if (oldMsg is IUserMessage oldUserMsg) await oldUserMsg.UnpinAsync(); } - catch { } + catch (Exception ex) + { + Logger.Error(ex.Message); + } } try { await sent.PinAsync(); } - catch { } + catch (Exception ex) + { + Logger.Error(ex.Message); + } _pinStateRepository.Upsert( pinnableMessage.PinSlot, pinnableMessage.DiscordChannelId, sent.Id); } - catch { } + catch (Exception ex) + { + Logger.Error(ex.Message); + } }); } } From 04d69109db3d857b17a13cdde4ec4e3155c0d73c Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 13:31:14 +0500 Subject: [PATCH 33/58] docs: add ISSUE-024 automarket crafter viability design spec Co-Authored-By: Claude Sonnet 4.6 --- ...024-automarket-crafter-viability-design.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-issue-024-automarket-crafter-viability-design.md diff --git a/docs/superpowers/specs/2026-05-28-issue-024-automarket-crafter-viability-design.md b/docs/superpowers/specs/2026-05-28-issue-024-automarket-crafter-viability-design.md new file mode 100644 index 0000000..543c484 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-issue-024-automarket-crafter-viability-design.md @@ -0,0 +1,283 @@ +# ISSUE-024 — AutoMarket Crafter Viability Design + +**Date:** 2026-05-28 +**Status:** Approved +**Backlog entry:** `docs/backlog/issues.md#ISSUE-024` + +--- + +## Problem Summary + +AutoMarket is positioned as a market maker (best price) rather than a backstop (last resort). This structurally eliminates player crafters from the production economy: + +- **Raw material buy orders** at `production_cost × 1.0` → farmers prefer AutoMarket over player crafters +- **Production item sell orders** at `production_cost × 1.0` → crafters cannot undercut AutoMarket +- **Raw material sell orders** at `production_cost × 2.0` → crafters buying from AutoMarket cannot profit +- **Raw material buy orders** uncapped → unbounded NIC injection as AutoMarket absorbs all farming output + +The goal is to reposition AutoMarket as a price backstop: the gap between AutoMarket prices and fair value is where player trade operates. + +--- + +## Solution Design + +### Part A — Config additions + +Four new params in `automarket_config`. All are tunable post-deploy without code changes. + +```sql +INSERT INTO automarket_config (param_name, param_value) VALUES + ('product_sell_margin', 1.2), + ('raw_mat_sell_multiplier', 1.5), + ('product_buyback_margin', 0.80), + ('daily_rawmat_budget_nic', 5000000); +``` + +| param_name | default | purpose | +|---|---|---| +| `product_sell_margin` | `1.2` | Product sell orders at `cost × 1.2` (was 1.0) | +| `raw_mat_sell_multiplier` | `1.5` | Raw mat sell orders at `cost × 1.5` (was 2.0) | +| `product_buyback_margin` | `0.80` | AutoMarket buys products back at `cost × 0.80` | +| `daily_rawmat_budget_nic` | `5000000` | Max NIC paid for raw material purchases per UTC calendar day | + +**Crafter viability with these defaults:** +A crafter sourcing raw materials below 1× market price (e.g., directly from farmers) can sell finished products below AutoMarket's 1.2× price and profit. A crafter buying from AutoMarket at 1.5× raw mat cost will still pay more than AutoMarket's 1.2× product price for fully AutoMarket-sourced production — but the 0.80× buyback floor guarantees an exit price for crafters in thin player markets, making crafting economically rational even in the worst case. + +--- + +### Part B — Schema additions + +#### B1. `rawmat_purchased` tracking table + +Mirrors `plasma_sold`. Tracks NIC paid for raw material AutoMarket buy order fulfillments, used by `usp_RefreshAutoMarketOrders` to enforce the daily budget cap. + +```sql +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) +); +``` + +#### B2. `sp_RecordRawMatPurchased` stored procedure + +Upserts into `rawmat_purchased`. Called from `Market.cs` whenever an AutoMarket raw material buy order is fulfilled. + +```sql +CREATE 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 +``` + +--- + +### Part C — `usp_RefreshAutoMarketOrders` changes + +Five targeted changes. Existing plasma logic and order structure are untouched. + +#### C1. Step 0 — Filter production items out of `automarket_unbought_resources` + +Without this fix, the new buyback orders (Step 6) would be captured in `automarket_unbought_resources` on the next refresh cycle, incorrectly inflating raw material purchase quantities for production items. + +Change the `automarket_unbought_resources` snapshot from: +```sql +WHERE isAutoOrder = 1 AND isSell = 0 + AND itemdefinition NOT IN (3271, 3272, 3273, 3274) +``` +To: +```sql +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND mi.itemdefinition NOT IN (3271, 3272, 3273, 3274) + AND NOT EXISTS ( + SELECT 1 FROM market_orders_configuration moc + INNER JOIN entitydefaults ed2 ON ed2.definitionname = moc.definitionname + WHERE ed2.definition = mi.itemdefinition + ) +``` + +This requires aliasing `marketitems` as `mi` in the snapshot query. + +#### C2. Step 3 — Product sell price margin + +Declare and apply `@product_sell_margin`: + +```sql +DECLARE @product_sell_margin FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'product_sell_margin' +); +``` + +Change the price column in the Step 3 INSERT from: +```sql +pc.production_cost_nic, +``` +To: +```sql +pc.production_cost_nic * @product_sell_margin, +``` + +#### C3. Step 4 — Raw material buy order daily budget cap + +Declare budget variables after the existing plasma budget block: + +```sql +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; +``` + +Add a budget guard to the Combined CTE's final INSERT: + +```sql +-- At the end of the Combined INSERT, add: +WHERE c.total_required_quantity > 0 + AND @remaining_rawmat_budget > 0; +``` + +This is a binary cap: if the daily budget is exhausted, no new raw material buy orders are posted for the day. The existing `automarket_unbought_resources` carry-forward mechanism means materials not purchased today will increase next-cycle buy quantities automatically. + +#### C4. Step 5 — Raw material sell price multiplier + +Declare and apply `@raw_mat_sell_multiplier`: + +```sql +DECLARE @raw_mat_sell_multiplier FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'raw_mat_sell_multiplier' +); +``` + +Change the hardcoded `* 2.0` in the Step 5 price column to `* @raw_mat_sell_multiplier`. + +#### C5a. `recalculate_raw_material_prices` cleanup extension + +Add one line to the existing 90-day cleanup block at the bottom of `recalculate_raw_material_prices`: + +```sql +DELETE FROM rawmat_purchased WHERE purchased_on < DATEADD(DAY, -90, @today); +``` + +Keeps the tracking table from growing unbounded. No new maintenance path. + +#### C5b. Step 6 (new) — Production item buyback orders + +Declare and apply `@product_buyback_margin`: + +```sql +DECLARE @product_buyback_margin FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'product_buyback_margin' +); +``` + +Add a new set-based INSERT after Step 5, using the already-resolved `@marketeid` and `@vendoreid` (TM base): + +```sql +-- Step 6: Production item buyback buy orders +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; +``` + +Quantity = `moc.amount` (same as sell order quantity). No separate config; tune `moc.amount` if needed. + +--- + +### Part D — C# changes in `Market.cs` + +Three locations in `FulfillSellOrderInstantly` where plasma fulfillment is recorded (lines ~779, ~804, ~836) each get a paired raw material recording block immediately after the plasma block: + +```csharp +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", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } +} +``` + +`CategoryFlags.cf_raw_material` naturally excludes production items (crafted components, robots) so buyback order fulfillments are not incorrectly counted as raw material purchases. + +No changes to `MarketAutoOrdersManager.cs` — all new order types are managed within the existing single refresh cycle. + +--- + +## Schema Change Summary + +| Object | Change type | Detail | +|---|---|---| +| `automarket_config` | Alter (insert rows) | 4 new config params | +| `rawmat_purchased` | New table | Daily raw mat purchase tracking | +| `sp_RecordRawMatPurchased` | New procedure | Upsert into `rawmat_purchased` | +| `usp_RefreshAutoMarketOrders` | Alter | Steps 0, 3, 4, 5 modified; Step 6 added | +| `recalculate_raw_material_prices` | Alter | Add 90-day cleanup of `rawmat_purchased` (Part C, section C5a) | +| `Market.cs` | Alter | 3 raw material fulfillment recording hooks | + +--- + +## Validation Steps + +1. Run `usp_RefreshAutoMarketOrders` manually. Confirm product sell orders now price at `production_cost × 1.2` and raw material sell orders at `production_cost × 1.5`. +2. Confirm product buyback buy orders exist on the TM base market (`isSell = 0`, prices at ~0.80× production cost). +3. Sell a raw material item (ore/mineral) to an AutoMarket buy order. Confirm a row appears in `rawmat_purchased` for today. +4. Set `daily_rawmat_budget_nic = 1` in `automarket_config`. Run refresh. Confirm raw material buy orders are absent. Restore to `5000000`. +5. Query `v_all_production_costs` — confirm no NULL `production_cost_nic` values (Step 3 price change flows through this view). +6. Confirm `automarket_unbought_resources` does NOT contain production item definitions after a refresh that includes buyback orders. +7. Build passes with 0 errors in `Market.cs`. + +--- + +## Regression Risk + +| Risk | Mitigation | +|---|---| +| Product sell prices suddenly higher by 20% | Expected — alerts players to new economy. Validate range sanity in step 1. | +| Raw mat sell prices lower (2.0× → 1.5×) changes crafting costs | Expected positive change. Production cost view uses `resource_market_prices` not sell order prices, so `v_all_production_costs` is unaffected. | +| Buyback orders inflate `automarket_unbought_resources` next cycle | Mitigated by Step C1 exclusion filter. Verify in validation step 6. | +| `rawmat_purchased` MERGE contention under high throughput | Low risk: fulfillments are sequential within a `TransactionScope`; same pattern used for plasma without issues. | +| Market.cs logic change at wrong fulfillment branch | All 3 plasma-recording branches get mirrored; added immediately after existing plasma blocks to keep structure identical. | From 168a754cec891480e6228c4b1f1d8132322a992d Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 13:39:40 +0500 Subject: [PATCH 34/58] docs: add ISSUE-024 crafter viability implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-28-issue-024-crafter-viability.md | 1541 +++++++++++++++++ 1 file changed, 1541 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-issue-024-crafter-viability.md diff --git a/docs/superpowers/plans/2026-05-28-issue-024-crafter-viability.md b/docs/superpowers/plans/2026-05-28-issue-024-crafter-viability.md new file mode 100644 index 0000000..fe3eea5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-issue-024-crafter-viability.md @@ -0,0 +1,1541 @@ +# ISSUE-024 AutoMarket Crafter Viability 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 production sell price margin, reduce raw material sell markup, add product buyback buy orders, and cap raw material purchase spending — repositioning AutoMarket as a price backstop rather than a market maker so player crafters have a viable economic role. + +**Architecture:** Schema-first (new table + config rows → new stored proc → two altered stored procs → C# hook). Each layer is validated before the next depends on it. All SQL changes ship as both live DDL (run in SSMS) and updated doc `.sql` files. All SQL is idempotent (`CREATE OR ALTER PROCEDURE`, `IF OBJECT_ID IS NULL` guards, `MERGE` for seed rows). + +**Tech Stack:** SQL Server T-SQL, C# 12 / .NET 8, `Db.Query()` pattern, `CategoryFlags.cf_raw_material`, `TransactionScope`. + +**Spec:** `docs/superpowers/specs/2026-05-28-issue-024-automarket-crafter-viability-design.md` + +--- + +## File Map + +| File | Action | +|---|---| +| SQL Server (live DB) | New table `rawmat_purchased`; 4 new rows in `automarket_config`; new proc `sp_RecordRawMatPurchased`; ALTER `recalculate_raw_material_prices`; ALTER `usp_RefreshAutoMarketOrders` | +| `docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql` | New — idempotent DDL for table + config rows | +| `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql` | New — `CREATE OR ALTER PROCEDURE` | +| `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` | Add rawmat cleanup line to 90-day window block | +| `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` | Replace with updated version (Step 0 filter, Steps 3/4/5 multipliers, new Step 6) | +| `docs/db_structure/database_schema_documentation.md` | Add `rawmat_purchased` table entry; add 4 rows to `automarket_config` seeded rows | +| `src/Perpetuum/Services/MarketEngine/Market.cs` | Add raw material purchase recording at 3 locations in `FulfillSellOrderInstantly` | + +--- + +## Task 1: Schema changes — `rawmat_purchased` table and `automarket_config` seed rows + +**Files:** +- Create: `docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql` +- Modify: `docs/db_structure/database_schema_documentation.md` + +- [ ] **Step 1.1: Execute DDL in SSMS** + +Run this in SSMS against the `perpetuumsa` database: + +```sql +BEGIN TRANSACTION; + +-- New table: daily raw material purchase tracking (mirrors plasma_sold) +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 config params for ISSUE-024 +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; +``` + +- [ ] **Step 1.2: Verify table and config rows** + +```sql +-- Table exists +SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME = 'rawmat_purchased' +ORDER BY ORDINAL_POSITION; +-- Expected: 4 rows — purchased_on DATE NOT NULL, item_definition INT NOT NULL, +-- quantity BIGINT NOT NULL, income FLOAT NOT NULL + +-- Config rows exist +SELECT param_name, param_value FROM automarket_config +WHERE param_name IN ('product_sell_margin','raw_mat_sell_multiplier', + 'product_buyback_margin','daily_rawmat_budget_nic') +ORDER BY param_name; +-- Expected: 4 rows with values 0.8, 5000000, 1.2, 1.5 +``` + +- [ ] **Step 1.3: Create migration file** + +Create `docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql` with the exact DDL from Step 1.1. + +```sql +-- ISSUE-024: AutoMarket Crafter Viability +-- Creates rawmat_purchased tracking table and seeds new automarket_config params. +-- Run sp changes separately via the updated .sql files in docs/db_structure/stored_procedures/ +-- in this order: sp_RecordRawMatPurchased, recalculate_raw_material_prices, usp_RefreshAutoMarketOrders. + +BEGIN TRANSACTION; + +-- New table: daily raw material purchase tracking (mirrors plasma_sold) +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 config params for ISSUE-024 +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; +``` + +- [ ] **Step 1.4: Update schema documentation** + +In `docs/db_structure/database_schema_documentation.md`: + +**a)** Find the `automarket_config` seeded rows table and add 4 new rows: + +```markdown +| `product_sell_margin` | `1.2` | +| `raw_mat_sell_multiplier` | `1.5` | +| `product_buyback_margin` | `0.80` | +| `daily_rawmat_budget_nic` | `5000000.0` | +``` + +**b)** Find the alphabetically correct position for `rawmat_purchased` (after `raw_material_prices`, before `resource_market_prices` or similar) and add: + +```markdown +## rawmat_purchased + +**Schema:** `dbo` + +### Purpose + +Daily tracking of NIC paid out for raw material AutoMarket buy order fulfillments. Used by `usp_RefreshAutoMarketOrders` to enforce the `daily_rawmat_budget_nic` cap. Populated by `sp_RecordRawMatPurchased` (called from `Market.cs`). Rolling 90-day window maintained by `recalculate_raw_material_prices`. + +### Columns + +| Column | Definition | +|---|---| +| `purchased_on` | `date [not null, pk]` | +| `item_definition` | `int [not null, pk]` | +| `quantity` | `bigint [not null]` | +| `income` | `float [not null]` | + +--- +``` + +- [ ] **Step 1.5: Commit** + +```bash +git add docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql +git add docs/db_structure/database_schema_documentation.md +git commit -m "feat(db): add rawmat_purchased table and ISSUE-024 automarket_config params" +``` + +--- + +## Task 2: Create `sp_RecordRawMatPurchased` + extend `recalculate_raw_material_prices` + +**Files:** +- Create: `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql` +- Modify: `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` + +- [ ] **Step 2.1: Create `sp_RecordRawMatPurchased` in SSMS** + +```sql +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 +``` + +- [ ] **Step 2.2: Verify `sp_RecordRawMatPurchased` works** + +```sql +-- Insert two rows for the same item on the same day (should merge to one row) +EXEC sp_RecordRawMatPurchased + @purchased_on = '2026-01-01', @item_def = 999999, @quantity = 100, @income = 1000.0; +EXEC sp_RecordRawMatPurchased + @purchased_on = '2026-01-01', @item_def = 999999, @quantity = 50, @income = 500.0; + +SELECT * FROM rawmat_purchased WHERE item_definition = 999999; +-- Expected: 1 row — purchased_on='2026-01-01', item_definition=999999, +-- quantity=150, income=1500.0 + +-- Clean up +DELETE FROM rawmat_purchased WHERE item_definition = 999999; +``` + +- [ ] **Step 2.3: Create the doc file** + +Create `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql`: + +```sql +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 +``` + +- [ ] **Step 2.4: Alter `recalculate_raw_material_prices` in SSMS to add rawmat cleanup** + +The only change is adding one DELETE line to the existing 90-day cleanup block. Execute: + +```sql +CREATE OR ALTER PROCEDURE [dbo].[recalculate_raw_material_prices] +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @today DATE = CAST(GETUTCDATE() AS DATE); + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, @today) + 2, @today); + DECLARE @start_date DATE = DATEADD(DAY, -7, @today); + + DECLARE @anchor_fraction FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'plasma_anchor_fraction' + ); + DECLARE @ds_min FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_min' + ); + DECLARE @ds_max FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_max' + ); + + -- Alpha common plasma price as the anchor + DECLARE @plasma_anchor FLOAT = ( + SELECT TOP 1 dynamic_price + FROM fn_CalculateDynamicPlasmaPrices(1) + WHERE plasma_type = 'def_common_reactor_plasma' + ) * @anchor_fraction; + + -- Compute and upsert new prices for all raw materials in the production chain + WITH + supply AS ( + SELECT + resource_name, + SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END) AS pvp_qty, + SUM(quantity) AS total_qty, + SUM(quantity) / 7.0 AS supply_daily_avg + FROM resources_gathered + WHERE gathered_on >= @start_date + GROUP BY resource_name + ), + 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 + ), + priced AS ( + SELECT + m.resource_name, + ROUND( + @plasma_anchor + * CASE + WHEN s.supply_daily_avg IS NULL OR s.supply_daily_avg = 0 + THEN @ds_max + ELSE + CASE + WHEN ISNULL(d.daily_demand, 0) / s.supply_daily_avg < @ds_min THEN @ds_min + WHEN ISNULL(d.daily_demand, 0) / s.supply_daily_avg > @ds_max THEN @ds_max + ELSE ISNULL(d.daily_demand, 0) / s.supply_daily_avg + END + END + * (1.0 + ISNULL( + CAST(s.pvp_qty AS FLOAT) / NULLIF(s.total_qty, 0), + 1.0 + )), + 2 + ) AS new_price + FROM materials m + LEFT JOIN supply s ON s.resource_name = m.resource_name + LEFT JOIN demand_cte d ON d.raw_material = m.resource_name + ) + MERGE INTO dbo.resource_market_prices AS target + USING priced AS source + ON target.calculated_on = @week_start + AND target.resource_name COLLATE DATABASE_DEFAULT = source.resource_name COLLATE DATABASE_DEFAULT + WHEN MATCHED THEN + UPDATE SET unit_price = source.new_price + WHEN NOT MATCHED THEN + INSERT (calculated_on, resource_name, unit_price) + VALUES (@week_start, source.resource_name, source.new_price); + + -- Cleanup old stats (90-day rolling window) + DELETE FROM plasma_gathered WHERE gathered_on < DATEADD(DAY, -90, @today); + DELETE FROM plasma_sold WHERE sold_on < DATEADD(DAY, -90, @today); + DELETE FROM resources_gathered WHERE gathered_on < DATEADD(DAY, -90, @today); + DELETE FROM rawmat_purchased WHERE purchased_on < DATEADD(DAY, -90, @today); +END; +GO +``` + +- [ ] **Step 2.5: Verify cleanup line is present** + +```sql +SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.recalculate_raw_material_prices')); +-- Expected: 'rawmat_purchased' appears in the output +``` + +- [ ] **Step 2.6: Update `recalculate_raw_material_prices` doc file** + +Replace the full content of `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` with: + +```sql +USE [perpetuumsa] +GO +/****** Object: StoredProcedure [dbo].[recalculate_raw_material_prices] Script Date: 28.05.2026 ******/ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +---- Dynamic supply/demand + PvP-risk formula anchored to live plasma prices + +CREATE OR ALTER PROCEDURE [dbo].[recalculate_raw_material_prices] +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @today DATE = CAST(GETUTCDATE() AS DATE); + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, @today) + 2, @today); + DECLARE @start_date DATE = DATEADD(DAY, -7, @today); + + DECLARE @anchor_fraction FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'plasma_anchor_fraction' + ); + DECLARE @ds_min FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_min' + ); + DECLARE @ds_max FLOAT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'resource_ds_ratio_max' + ); + + -- Alpha common plasma price as the anchor + DECLARE @plasma_anchor FLOAT = ( + SELECT TOP 1 dynamic_price + FROM fn_CalculateDynamicPlasmaPrices(1) + WHERE plasma_type = 'def_common_reactor_plasma' + ) * @anchor_fraction; + + -- Compute and upsert new prices for all raw materials in the production chain + WITH + supply AS ( + SELECT + resource_name, + SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END) AS pvp_qty, + SUM(quantity) AS total_qty, + SUM(quantity) / 7.0 AS supply_daily_avg + FROM resources_gathered + WHERE gathered_on >= @start_date + GROUP BY resource_name + ), + 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 + ), + priced AS ( + SELECT + m.resource_name, + ROUND( + @plasma_anchor + * CASE + WHEN s.supply_daily_avg IS NULL OR s.supply_daily_avg = 0 + THEN @ds_max + ELSE + CASE + WHEN ISNULL(d.daily_demand, 0) / s.supply_daily_avg < @ds_min THEN @ds_min + WHEN ISNULL(d.daily_demand, 0) / s.supply_daily_avg > @ds_max THEN @ds_max + ELSE ISNULL(d.daily_demand, 0) / s.supply_daily_avg + END + END + * (1.0 + ISNULL( + CAST(s.pvp_qty AS FLOAT) / NULLIF(s.total_qty, 0), + 1.0 + )), + 2 + ) AS new_price + FROM materials m + LEFT JOIN supply s ON s.resource_name = m.resource_name + LEFT JOIN demand_cte d ON d.raw_material = m.resource_name + ) + MERGE INTO dbo.resource_market_prices AS target + USING priced AS source + ON target.calculated_on = @week_start + AND target.resource_name COLLATE DATABASE_DEFAULT = source.resource_name COLLATE DATABASE_DEFAULT + WHEN MATCHED THEN + UPDATE SET unit_price = source.new_price + WHEN NOT MATCHED THEN + INSERT (calculated_on, resource_name, unit_price) + VALUES (@week_start, source.resource_name, source.new_price); + + -- Cleanup old stats (90-day rolling window) + DELETE FROM plasma_gathered WHERE gathered_on < DATEADD(DAY, -90, @today); + DELETE FROM plasma_sold WHERE sold_on < DATEADD(DAY, -90, @today); + DELETE FROM resources_gathered WHERE gathered_on < DATEADD(DAY, -90, @today); + DELETE FROM rawmat_purchased WHERE purchased_on < DATEADD(DAY, -90, @today); +END; +GO +``` + +- [ ] **Step 2.7: Commit** + +```bash +git add docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql +git add docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql +git commit -m "feat(db): add sp_RecordRawMatPurchased; extend recalculate_raw_material_prices with rawmat cleanup (ISSUE-024)" +``` + +--- + +## Task 3: Modify `usp_RefreshAutoMarketOrders` + +**Files:** +- Modify: `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` + +Five changes from spec: Step 0 production item filter, Step 3 sell margin, Step 4 rawmat budget cap, Step 5 sell multiplier, new Step 6 buyback orders. + +- [ ] **Step 3.1: Execute `CREATE OR ALTER PROCEDURE` in SSMS** + +Run the following against `perpetuumsa`: + +```sql +CREATE OR ALTER PROCEDURE [dbo].[usp_RefreshAutoMarketOrders] +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRY + 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 production items from market_orders_configuration + 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 market_orders_configuration moc + WHERE moc.definitionname = ed.definitionname + ) + GROUP BY mi.itemdefinition; + + -- 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); + + 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); + + -- 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; + + -- Step 4: Raw material buy orders — skip 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 + ) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + c.combined_def, + @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; + + -- Step 5: Raw resource sell orders — price at cost * raw_mat_sell_multiplier + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + 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; + + -- 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; + + END TRY + BEGIN CATCH + PRINT 'Error in usp_RefreshAutoMarketOrders: ' + ERROR_MESSAGE(); + THROW; + END CATCH +END; +GO +``` + +- [ ] **Step 3.2: Verify product sell prices are at 1.2×** + +```sql +EXEC usp_RefreshAutoMarketOrders; + +-- Product sell orders should be at production_cost * 1.2 +-- Join market_orders_configuration to identify production items (excludes plasma and raw mats) +SELECT mi.price, pc.production_cost_nic, mi.price / pc.production_cost_nic AS ratio, + ed.definitionname +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +JOIN v_all_production_costs pc ON ed.definitionname = pc.product +JOIN market_orders_configuration moc ON moc.definitionname = ed.definitionname +WHERE mi.isAutoOrder = 1 AND mi.isSell = 1 +ORDER BY ed.definitionname; +-- Expected: ratio column ≈ 1.2 for all production item sell orders +``` + +- [ ] **Step 3.3: Verify raw material sell prices are at 1.5×** + +```sql +SELECT mi.price, pc.production_cost_nic, mi.price / pc.production_cost_nic AS ratio, + ed.definitionname +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +JOIN v_all_production_costs pc ON ed.definitionname = pc.product +WHERE mi.isAutoOrder = 1 AND mi.isSell = 1 + AND mi.quantity = 10000000 +ORDER BY ed.definitionname; +-- Expected: ratio column ≈ 1.5 for all raw material sell orders +``` + +- [ ] **Step 3.4: Verify product buyback orders exist** + +```sql +SELECT mi.price, pc.production_cost_nic, mi.price / pc.production_cost_nic AS ratio, + ed.definitionname +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +JOIN v_all_production_costs pc ON ed.definitionname = pc.product +JOIN market_orders_configuration moc ON moc.definitionname = ed.definitionname +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND ed.definitionname NOT LIKE '%plasma%' +ORDER BY ed.definitionname; +-- Expected: ratio column ≈ 0.80 for all production item buyback orders +-- Row count should match the number of rows in market_orders_configuration +``` + +- [ ] **Step 3.5: Verify buyback orders are excluded from `automarket_unbought_resources` on next refresh** + +```sql +-- Run refresh again to simulate next cycle +EXEC usp_RefreshAutoMarketOrders; + +-- Check that automarket_unbought_resources contains only raw materials, not production items +SELECT ubr.itemdefinition, ed.definitionname +FROM automarket_unbought_resources ubr +JOIN entitydefaults ed ON ed.definition = ubr.itemdefinition +-- Should NOT contain any definitionname that is in market_orders_configuration +WHERE EXISTS ( + SELECT 1 FROM market_orders_configuration moc WHERE moc.definitionname = ed.definitionname +); +-- Expected: 0 rows +``` + +- [ ] **Step 3.6: Verify rawmat budget cap** + +```sql +-- Set budget to 1 NIC to force all raw mat buy orders to be skipped +UPDATE automarket_config SET param_value = 1 WHERE param_name = 'daily_rawmat_budget_nic'; + +EXEC usp_RefreshAutoMarketOrders; + +SELECT COUNT(*) AS raw_mat_buy_orders +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND ed.definitionname NOT LIKE '%plasma%' + AND NOT EXISTS ( + SELECT 1 FROM market_orders_configuration moc WHERE moc.definitionname = ed.definitionname + ); +-- Expected: 0 + +-- Restore budget +UPDATE automarket_config SET param_value = 5000000 WHERE param_name = 'daily_rawmat_budget_nic'; +EXEC usp_RefreshAutoMarketOrders; +``` + +- [ ] **Step 3.7: Update the doc file** + +Replace the full content of `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` with: + +```sql +USE [perpetuumsa] +GO +/****** Object: StoredProcedure [dbo].[usp_RefreshAutoMarketOrders] Script Date: 28.05.2026 ******/ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +---- Place auto market orders: plasma buy orders with daily budget cap; raw material orders with +---- daily NIC budget cap; product sell orders at margin; raw material sell orders at multiplier; +---- product buyback buy orders at backstop price. +---- Cursors replaced with set-based INSERTs. Views materialised into temp tables to avoid +---- recursive-CTE re-evaluation. + +CREATE OR ALTER PROCEDURE [dbo].[usp_RefreshAutoMarketOrders] +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRY + 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 production items (market_orders_configuration) + 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 market_orders_configuration moc + WHERE moc.definitionname = ed.definitionname + ) + GROUP BY mi.itemdefinition; + + -- 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); + + 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); + + -- 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; + + -- 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 + ) + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + c.combined_def, + @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; + + -- Step 5: Raw resource sell orders — price at cost * raw_mat_sell_multiplier + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + 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; + + -- 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; + + END TRY + BEGIN CATCH + PRINT 'Error in usp_RefreshAutoMarketOrders: ' + ERROR_MESSAGE(); + THROW; + END CATCH +END; +GO +``` + +- [ ] **Step 3.8: Commit** + +```bash +git add docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql +git commit -m "feat(db): update usp_RefreshAutoMarketOrders — sell margins, rawmat budget cap, buyback orders (ISSUE-024)" +``` + +--- + +## Task 4: Add raw material purchase tracking to `Market.cs` + +**Files:** +- Modify: `src/Perpetuum/Services/MarketEngine/Market.cs` + +Three additions inside `FulfillSellOrderInstantly`. All three mirror the immediately preceding plasma recording block, using `cf_raw_material` instead of `cf_reactor_plasma`. + +`isAutoOrder` is not loaded into `MarketOrder` — the `buyOrder.isVendorItem` guard is sufficient because only AutoMarket posts vendor buy orders for raw materials. + +- [ ] **Step 4.1: Add recording at partial fulfillment branch (after line 790)** + +In `Market.cs`, locate this exact block (lines 776–790): + +```csharp + // Log plasma sold and income earned + if (itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_reactor_plasma)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordPlasmaSold @sold_on, @plasma_type, @quantity, @income") + .SetParameter("@sold_on", DateTime.UtcNow) + .SetParameter("@plasma_type", itemToSell.ED.Name) + .SetParameter("@quantity", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } +``` + +Add the raw material block immediately after it (before `quantity = buyOrder.quantity;`): + +```csharp + // Log plasma sold and income earned + if (itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_reactor_plasma)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordPlasmaSold @sold_on, @plasma_type, @quantity, @income") + .SetParameter("@sold_on", DateTime.UtcNow) + .SetParameter("@plasma_type", itemToSell.ED.Name) + .SetParameter("@quantity", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + + // 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", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } +``` + +- [ ] **Step 4.2: Add recording at post-finite block (after line 815, before `return`)** + +Locate this exact block (lines 801–815): + +```csharp + // Log plasma sold and income earned + if (itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_reactor_plasma)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordPlasmaSold @sold_on, @plasma_type, @quantity, @income") + .SetParameter("@sold_on", DateTime.UtcNow) + .SetParameter("@plasma_type", itemToSell.ED.Name) + .SetParameter("@quantity", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + + return; +``` + +Add the raw material block immediately before `return`: + +```csharp + // Log plasma sold and income earned + if (itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_reactor_plasma)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordPlasmaSold @sold_on, @plasma_type, @quantity, @income") + .SetParameter("@sold_on", DateTime.UtcNow) + .SetParameter("@plasma_type", itemToSell.ED.Name) + .SetParameter("@quantity", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + + // 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", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + + return; +``` + +- [ ] **Step 4.3: Add recording at infinite vendor buy order path (after line 850)** + +Locate this exact block (lines 836–850): + +```csharp + // Log plasma sold and income earned + if (itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_reactor_plasma)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordPlasmaSold @sold_on, @plasma_type, @quantity, @income") + .SetParameter("@sold_on", DateTime.UtcNow) + .SetParameter("@plasma_type", itemToSell.ED.Name) + .SetParameter("@quantity", itemToSell.Quantity) + .SetParameter("@income", buyOrder.price * itemToSell.Quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + } +``` + +Add the raw material block before the closing `}` of the method: + +```csharp + // Log plasma sold and income earned + if (itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_reactor_plasma)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordPlasmaSold @sold_on, @plasma_type, @quantity, @income") + .SetParameter("@sold_on", DateTime.UtcNow) + .SetParameter("@plasma_type", itemToSell.ED.Name) + .SetParameter("@quantity", itemToSell.Quantity) + .SetParameter("@income", buyOrder.price * itemToSell.Quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + + // 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", itemToSell.Quantity) + .SetParameter("@income", buyOrder.price * itemToSell.Quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + } +``` + +- [ ] **Step 4.4: Build** + +```bash +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: `Build succeeded. 0 Error(s)` with no warnings in `Market.cs`. + +- [ ] **Step 4.5: Commit** + +```bash +git add src/Perpetuum/Services/MarketEngine/Market.cs +git commit -m "feat: track raw material AutoMarket purchases in Market.cs for daily NIC budget cap (ISSUE-024)" +``` + +--- + +## Task 5: End-to-end validation and backlog update + +**Files:** +- Modify: `docs/backlog/issues.md` + +- [ ] **Step 5.1: Confirm all config params are present** + +```sql +SELECT param_name, param_value FROM automarket_config ORDER BY param_name; +-- Expected: 9 rows including all original IMPROVEMENT-030 params plus: +-- daily_rawmat_budget_nic = 5000000 +-- product_buyback_margin = 0.8 +-- product_sell_margin = 1.2 +-- raw_mat_sell_multiplier = 1.5 +``` + +- [ ] **Step 5.2: Run full refresh and verify all order types** + +```sql +EXEC usp_RefreshAutoMarketOrders; + +-- All auto order types +SELECT + CASE mi.isSell WHEN 1 THEN 'sell' ELSE 'buy' END AS order_type, + CASE + WHEN ed.definitionname LIKE '%plasma%' THEN 'plasma' + WHEN EXISTS (SELECT 1 FROM market_orders_configuration moc + WHERE moc.definitionname = ed.definitionname) THEN 'production_item' + ELSE 'raw_material' + END AS item_class, + COUNT(*) AS order_count, + MIN(mi.price) AS min_price, + MAX(mi.price) AS max_price +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 +GROUP BY mi.isSell, + CASE + WHEN ed.definitionname LIKE '%plasma%' THEN 'plasma' + WHEN EXISTS (SELECT 1 FROM market_orders_configuration moc + WHERE moc.definitionname = ed.definitionname) THEN 'production_item' + ELSE 'raw_material' + END +ORDER BY item_class, order_type; +-- Expected rows: +-- plasma / buy — plasma buy orders exist, count > 0 +-- production_item / sell — sell orders at 1.2× cost, count > 0 +-- production_item / buy — buyback orders at 0.80× cost, count > 0 (NEW) +-- raw_material / sell — sell orders at 1.5× cost, count > 0 +-- raw_material / buy — buy orders at 1× cost, count > 0 (when budget > 0) +``` + +- [ ] **Step 5.3: Sell a raw material item to AutoMarket (requires running server)** + +Have a player sell a raw material item (ore, mineral, liquid) to the AutoMarket buy order. After the transaction: + +```sql +SELECT * FROM rawmat_purchased +WHERE purchased_on = CAST(GETUTCDATE() AS DATE); +-- Expected: a row with the sold item's definition, positive quantity, positive income +``` + +- [ ] **Step 5.4: Confirm no NULL production costs** + +```sql +SELECT COUNT(*) FROM v_all_production_costs WHERE production_cost_nic IS NULL; +-- Expected: 0 + +SELECT COUNT(*) FROM v_all_production_costs WHERE production_cost_nic <= 0; +-- Expected: 0 +``` + +- [ ] **Step 5.5: Update ISSUE-024 backlog status** + +In `docs/backlog/issues.md`, change the ISSUE-024 entry: + +```markdown +Status: DONE +``` + +- [ ] **Step 5.6: Commit backlog update** + +```bash +git add docs/backlog/issues.md +git commit -m "docs: mark ISSUE-024 as DONE" +``` + +--- + +## Regression Checklist + +Before considering complete, verify: + +| Risk | Check | +|---|---| +| Product sell prices higher by 20% | Expected. Verify ratio ≈ 1.2 in Step 3.2. | +| Raw mat sell prices lower (2.0 → 1.5) | Expected positive change. `v_all_production_costs` uses `resource_market_prices` (not sell order prices) so production cost calculations are unaffected. | +| Buyback orders inflate `automarket_unbought_resources` | Verified by Step 3.5. | +| Plasma buy orders unchanged | No changes to Steps 1.1–1.3. Verify plasma orders still appear with correct quantities in Step 5.2. | +| `rawmat_purchased` MERGE race condition | Low risk; same `TransactionScope` pattern used for plasma without issues. | +| Build success | Verified in Step 4.4. | From a418fd809a976c04f623d3ac461c71023febe7f0 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 13:47:20 +0500 Subject: [PATCH 35/58] feat(db): add rawmat_purchased table and ISSUE-024 automarket_config params --- .../database_schema_documentation.md | 39 +++++++++++++++---- .../20260528_issue_024_crafter_viability.sql | 26 +++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 docs/db_structure/migrations/20260528_issue_024_crafter_viability.sql diff --git a/docs/db_structure/database_schema_documentation.md b/docs/db_structure/database_schema_documentation.md index 977eb85..edffe11 100644 --- a/docs/db_structure/database_schema_documentation.md +++ b/docs/db_structure/database_schema_documentation.md @@ -1004,13 +1004,17 @@ Generated from DBML structure. ### Seeded rows -| param_name | param_value | -|---|---| -| `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` | +| 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. | --- @@ -5354,6 +5358,27 @@ Stores the Discord channel ID and message ID of the currently pinned message per --- +## 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` 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; From 50ef6274f5096481781537270e564fa85eb96aca Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 13:49:28 +0500 Subject: [PATCH 36/58] feat(db): add sp_RecordRawMatPurchased; extend recalculate_raw_material_prices with rawmat cleanup (ISSUE-024) --- ...te_raw_material_prices.StoredProcedure.sql | Bin 10740 -> 7664 bytes ..._RecordRawMatPurchased.StoredProcedure.sql | 32 ++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 docs/db_structure/stored_procedures/dbo.sp_RecordRawMatPurchased.StoredProcedure.sql 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 033278d0b2109fba37d26f01213bec3d3a950a1f..cdcde77f394fde10ed704ff3a4b767a4c0399901 100644 GIT binary patch literal 7664 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 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 From 641e54c116cb1e6e0c64358041b0de6467a1ec66 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 13:52:24 +0500 Subject: [PATCH 37/58] =?UTF-8?q?feat(db):=20update=20usp=5FRefreshAutoMar?= =?UTF-8?q?ketOrders=20=E2=80=94=20sell=20margins,=20rawmat=20budget=20cap?= =?UTF-8?q?,=20buyback=20orders=20(ISSUE-024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efreshAutoMarketOrders.StoredProcedure.sql | Bin 23010 -> 13611 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 5ed2973485e069b9cc4363374581b17449ae8d96..1ae53d52c5915e2fa79f922adc30667455d331ef 100644 GIT binary patch literal 13611 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 Date: Thu, 28 May 2026 14:01:24 +0500 Subject: [PATCH 38/58] feat: track raw material AutoMarket purchases in Market.cs for daily NIC budget cap (ISSUE-024) --- src/Perpetuum/Services/MarketEngine/Market.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/Perpetuum/Services/MarketEngine/Market.cs b/src/Perpetuum/Services/MarketEngine/Market.cs index efd3d95..a8794b7 100644 --- a/src/Perpetuum/Services/MarketEngine/Market.cs +++ b/src/Perpetuum/Services/MarketEngine/Market.cs @@ -789,6 +789,22 @@ public void FulfillSellOrderInstantly(Character seller, bool useSellerCorporatio } } + // 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(); + } + } + quantity = buyOrder.quantity; buyOrder.quantity = 0; //signal to client @@ -814,6 +830,22 @@ public void FulfillSellOrderInstantly(Character seller, bool useSellerCorporatio } } + // 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", quantity) + .SetParameter("@income", buyOrder.price * quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } + return; } @@ -848,6 +880,22 @@ public void FulfillSellOrderInstantly(Character seller, bool useSellerCorporatio scope.Complete(); } } + + // 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", itemToSell.Quantity) + .SetParameter("@income", buyOrder.price * itemToSell.Quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } } ///

From 7d0b9434ff8ceab4f7d9543738ed6624873d784b Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 14:01:47 +0500 Subject: [PATCH 39/58] docs: mark ISSUE-024 as DONE --- docs/backlog/issues.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/backlog/issues.md b/docs/backlog/issues.md index 2004ebe..0738be9 100644 --- a/docs/backlog/issues.md +++ b/docs/backlog/issues.md @@ -1,6 +1,43 @@ # Last ID used -023 +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 From ff96ce366c140d87e5e0c02a7e2eb2e063a05754 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 17:35:11 +0500 Subject: [PATCH 40/58] docs: add IMPROVEMENT-031 AdminTool AutoMarket panel design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-28-automarket-admintool-design.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-automarket-admintool-design.md diff --git a/docs/superpowers/specs/2026-05-28-automarket-admintool-design.md b/docs/superpowers/specs/2026-05-28-automarket-admintool-design.md new file mode 100644 index 0000000..8f9c75f --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-automarket-admintool-design.md @@ -0,0 +1,283 @@ +# IMPROVEMENT-031: AdminTool AutoMarket Panel — Design Spec + +**Date:** 2026-05-28 +**Status:** Approved +**Backlog:** IMPROVEMENT-031 + +--- + +## Overview + +Add a dedicated **AutoMarket** panel to the AdminTool with four tabs: Config, Trade List, Statistics, and Orders. Follows the Seasons/EquipmentSets module folder pattern: namespace-isolated repository and model types, per-tab ViewModels, XAML views, wired into `MainViewModel`. + +No server-side changes. "Refresh Now" calls the stored procedures directly from the AdminTool DB connection — the same two calls the server's `MarketAutoOrdersManager` makes. + +--- + +## 1. Structure + +### New folder: `src/Perpetuum.AdminTool/AutoMarket/` + +| File | Purpose | +|---|---| +| `AutoMarketRepository.cs` | All DB queries for the panel | +| `AutoMarketConfigRow.cs` | Row model: `param_name`, `param_value`, `Label`, `Description`, `OriginalValue` | +| `AutoMarketTradeListRow.cs` | Row model: `DefinitionName`, `Amount`, `DisplayName`, `OriginalAmount` | +| `AutoMarketRawMaterialRow.cs` | Read-only derived row: `RawMaterialName`, `TotalQuantity` | +| `AutoMarketNicFlowRow.cs` | Statistics row: period, plasma_in, rawmat_out, net, budget_pct | +| `AutoMarketPricingTraceRow.cs` | Statistics row: resource, anchor, sd_ratio, risk_mult, computed_price, stored_price | +| `AutoMarketGatherRow.cs` | Statistics row: resource, pve_qty, pvp_qty, total_qty, pvp_pct | +| `AutoMarketOrderRow.cs` | Orders row: display_name, order_type, price, amount, market_name, category | + +### New ViewModels: `src/Perpetuum.AdminTool/ViewModels/` + +| File | Purpose | +|---|---| +| `AutoMarketViewModel.cs` | Root VM — owns tab VMs, `RefreshNowCommand` | +| `AutoMarketConfigViewModel.cs` | Tab 1 — editable config grid | +| `AutoMarketTradeListViewModel.cs` | Tab 2 — editable trade list + derived materials sub-panel | +| `AutoMarketStatisticsViewModel.cs` | Tab 3 — read-only NIC flow, pricing trace, gather breakdown | +| `AutoMarketOrdersViewModel.cs` | Tab 4 — read-only live order snapshot with filters | + +### New Views: `src/Perpetuum.AdminTool/Views/` + +| File | Purpose | +|---|---| +| `AutoMarketView.xaml` | Tab control shell | +| `AutoMarketConfigView.xaml` | Config grid | +| `AutoMarketTradeListView.xaml` | Trade list grid + raw materials sub-panel | +| `AutoMarketStatisticsView.xaml` | Three statistics panels | +| `AutoMarketOrdersView.xaml` | Orders grid with filter controls | + +### MainViewModel wiring + +```csharp +public AutoMarketViewModel AutoMarket { get; } + +// In constructor: +AutoMarket = new AutoMarketViewModel( + new AutoMarketRepository(store.Settings.Connection), + session.Changes, + session.Lookups, + Translations); +``` + +--- + +## 2. Tab 1 — Config + +### Data source +`automarket_config` — key-value store (`param_name VARCHAR(100)`, `param_value FLOAT`). + +### AutoMarketConfigRow +``` +param_name string (read-only key) +param_value double (editable) +Label string (human-readable, resolved from hardcoded map) +Description string (tooltip text) +OriginalValue double (set on load; dirty detection) +IsDirty bool (param_value != OriginalValue) +``` + +### Label map (9 params) + +| param_name | Label | Description | +|---|---|---| +| `plasma_anchor_fraction` | Plasma Anchor Fraction | Fraction of alpha plasma price used as raw material pricing anchor | +| `plasma_buy_qty_fraction` | Plasma Buy Quantity | Fraction of gathered plasma placed as buy orders | +| `daily_plasma_budget_nic` | Daily Plasma Budget (NIC) | Max NIC spent on plasma buy orders per calendar day | +| `daily_rawmat_budget_nic` | Daily Rawmat Budget (NIC) | Max NIC spent on raw material buy orders per calendar day | +| `resource_ds_ratio_min` | S/D Ratio Min | Lower clamp for supply/demand ratio in pricing formula | +| `resource_ds_ratio_max` | S/D Ratio Max | Upper clamp for supply/demand ratio in pricing formula | +| `product_sell_margin` | Product Sell Margin | Production item sell orders priced at production_cost × this value | +| `raw_mat_sell_multiplier` | Rawmat Sell Multiplier | Raw material sell orders priced at production_cost × this value | +| `product_buyback_margin` | Product Buyback Margin | Buyback buy orders priced at production_cost × this value | + +### AutoMarketConfigViewModel +- `LoadAsync()` — queries all `automarket_config` rows, joins to label map, populates `Rows` +- `ObservableCollection Rows` +- `QueueSaveCommand(AutoMarketConfigRow row)` — generates `UPDATE automarket_config SET param_value = @v WHERE param_name = @k`; pushes to `ChangeQueue`; deduplication key: `automarket_config:{param_name}` + +### Refresh Now +Lives on root `AutoMarketViewModel`. Executes: +```sql +EXEC recalculate_raw_material_prices; +EXEC usp_RefreshAutoMarketOrders; +``` +Runs as a direct DB operation (`Task.Run` + `SqlConnection`). Loading indicator while running; disabled while in progress. Surfaces errors via `MessageBox`. + +--- + +## 3. Tab 2 — Trade List + +### Data sources +- `market_orders_configuration` (`definitionname`, `amount`) — editable grid +- `v_required_raw_materials` (`product`, `raw_material`, `total_quantity`) — read-only derived sub-panel +- `entitydefaults` (via LookupCache) + `TranslationsViewModel` — display names and item picker + +### AutoMarketTradeListRow +``` +DefinitionName string (read-only key) +Amount int (editable) +DisplayName string (translated name; fallback: DefinitionName) +OriginalAmount int (dirty detection) +IsDirty bool +``` + +### AutoMarketRawMaterialRow +``` +RawMaterialName string (raw resource_name string — not an integer ID) +TotalQuantity long (SUM across all products in current trade list) +``` + +### AutoMarketTradeListViewModel +- `ObservableCollection Rows` +- `ObservableCollection DerivedMaterials` +- `LoadAsync()` — loads trade list; resolves display names via `TranslationsViewModel`; loads derived materials from `v_required_raw_materials` grouped by `raw_material` +- `QueueSaveCommand(row)` — `UPDATE market_orders_configuration SET amount = @a WHERE definitionname = @d`; deduplication key: `market_orders_configuration:{definitionname}` +- `RemoveCommand(row)` — `DELETE FROM market_orders_configuration WHERE definitionname = @d`; queued; warns via `MessageBox` if the item appears as a dependency in `v_required_raw_materials` +- `AddItemCommand` — opens item picker dialog + +### Item picker dialog (`AutoMarketItemPickerWindow`) +- Search box filtering `entitydefaults` rows from LookupCache +- Shows translated name + definition name +- Filtered to exclude items already in the trade list +- On confirm: queues `INSERT INTO market_orders_configuration (definitionname, amount) VALUES (@d, 1)`; adds row to `Rows` with `Amount = 1` + +### Derived materials sub-panel +- Read-only; sits below the trade list grid +- Refreshes after load, add, or remove +- Queries `v_required_raw_materials` filtered to current `definitionname` set +- `resource_name` strings displayed as-is (no integer ID; not in translation store) + +--- + +## 4. Tab 3 — Statistics + +Read-only, refresh-on-demand. Three panels. + +### Panel A — NIC Flow + +**Sources:** `plasma_sold` (sold_on, plasma_type, quantity, income), `rawmat_purchased` (purchased_on, item_definition, quantity, income), `automarket_config` (budget params). + +Three period rows: Today / Last 7 Days / All Time. + +``` +AutoMarketNicFlowRow: + Period string ("Today", "Last 7 Days", "All Time") + PlasmaIn long (SUM(income) from plasma_sold for period) + RawmatOut long (SUM(income) from rawmat_purchased for period) + NetDelta long (PlasmaIn - RawmatOut) + PlasmaBudgetPct double? (Today only: today_plasma_spent / daily_plasma_budget × 100) + RawmatBudgetPct double? (Today only: today_rawmat_spent / daily_rawmat_budget × 100) +``` + +### Panel B — Pricing Trace + +Live-computed from DB, mirroring the formula in `recalculate_raw_material_prices`. For each raw material in `v_required_raw_materials`. + +``` +AutoMarketPricingTraceRow: + ResourceName string + PlasmaAnchor double (fn_CalculateDynamicPlasmaPrices anchor × anchor_fraction) + SdRatio double (CLAMP(daily_demand / supply_daily_avg, ds_min, ds_max); ds_max if no supply) + RiskMultiplier double (1.0 + pvp_fraction; 2.0 if no gather data) + ComputedPrice double (ROUND(PlasmaAnchor × SdRatio × RiskMultiplier, 2)) + StoredPrice double? (latest unit_price from resource_market_prices — for comparison) +``` + +**Queries (run in parallel):** +1. `SELECT TOP 1 dynamic_price FROM fn_CalculateDynamicPlasmaPrices(1) WHERE plasma_type = 'def_common_reactor_plasma'` +2. All params from `automarket_config` +3. Supply: `SELECT resource_name, SUM(CASE WHEN is_pvp=1 THEN quantity ELSE 0 END) AS pvp_qty, SUM(quantity) AS total_qty, SUM(quantity)/7.0 AS supply_daily_avg FROM resources_gathered WHERE gathered_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) GROUP BY resource_name` +4. Demand: `SELECT raw_material, SUM(total_quantity)/7.0 AS daily_demand FROM v_required_raw_materials GROUP BY raw_material` +5. Materials list: `SELECT DISTINCT raw_material FROM v_required_raw_materials` +6. Stored prices: `SELECT resource_name, unit_price FROM resource_market_prices WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices)` + +Formula applied in C# — no SP call. + +### Panel C — Gather Breakdown + +``` +AutoMarketGatherRow: + ResourceName string + PveQty long + PvpQty long + TotalQty long + PvpPct double (PvpQty / TotalQty × 100; 0 if no data) +``` + +**Query:** `resources_gathered_daily` last 7 days, grouped by `resource_name`, split by `is_pvp`. + +### AutoMarketStatisticsViewModel +- `ObservableCollection NicFlow` +- `ObservableCollection PricingTrace` +- `ObservableCollection GatherBreakdown` +- `LoadAsync()` — runs all queries, populates collections +- `RefreshCommand` — re-runs `LoadAsync()`; disabled while loading + +--- + +## 5. Tab 4 — Orders + +Read-only live snapshot. Refresh-on-demand. + +### Data source +`marketitems WHERE isAutoOrder = 1`, joined to: +- `entitydefaults` for `definitionname` → translated display name +- Entity name lookup via `marketeid` for market/base display name + +### AutoMarketOrderRow +``` +DisplayName string (translated item name; fallback: definitionname) +OrderType string ("Buy" / "Sell" / "Buyback") +Price double +Amount int +MarketName string (translated market/base name; fallback: entity name; fallback: EID string) +Category string ("Plasma" / "Raw Material" / "Production Item") +``` + +### Category derivation (in C#) +- **Plasma:** `itemdefinition IN (3271, 3272, 3273, 3274)` +- **Production Item:** `definitionname` present in `market_orders_configuration` +- **Raw Material:** everything else + +### Order type derivation (in C#) +- `isSell = 1` → "Sell" +- `isSell = 0` + Plasma → "Buy" +- `isSell = 0` + Raw Material → "Buy" +- `isSell = 0` + Production Item → "Buyback" + +### Market name resolution +`marketitems.marketeid` → query `entities WHERE eid = @marketeid` for `name` → translate via TranslationsViewModel → fallback to entity name → fallback to EID string. + +### AutoMarketOrdersViewModel +- `ObservableCollection AllOrders` +- `ObservableCollection FilteredOrders` (bound to grid) +- `string? OrderTypeFilter` — null = all; changing re-applies filter +- `string? CategoryFilter` — null = all; changing re-applies filter +- `LoadAsync()` — queries, resolves names, derives category/type +- `RefreshCommand` — re-runs `LoadAsync()`; disabled while loading + +--- + +## 6. ChangeQueue Deduplication + +Config and Trade List tabs use deduplication keys per IMPROVEMENT-016 pattern: +- Config: `automarket_config:{param_name}` +- Trade List (update): `market_orders_configuration:{definitionname}` +- Trade List (delete): `market_orders_configuration:DELETE:{definitionname}` — replacing any prior non-destructive change for the same key + +Note: `ChangeQueue.Add` currently does not deduplicate. The key scheme is defined here so the implementation knows to implement deduplication in `ChangeQueue` or in the tab VMs (whichever matches IMPROVEMENT-016 when that is implemented). + +--- + +## 7. Constraints + +- No new DB tables or server-side changes required +- All data comes from tables and views introduced in IMPROVEMENT-030 and ISSUE-024 +- Translations: use existing `TranslationsViewModel` throughout; fallback to internal names — never show raw definition IDs +- `resource_name` strings in pricing/gather panels are not integer IDs and have no translation — display as-is +- Refresh Now must be disabled while a refresh is in progress; surfaces server-side errors to the operator +- Derived materials sub-panel is read-only — no ChangeQueue entries +- Statistics and Orders panels: read-only, no ChangeQueue involvement From 5a67a9a54ac1017a18d062a61096dd6fd3242638 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Thu, 28 May 2026 17:53:07 +0500 Subject: [PATCH 41/58] docs: add IMPROVEMENT-031 AutoMarket AdminTool implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-28-automarket-admintool.md | 2034 +++++++++++++++++ 1 file changed, 2034 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-automarket-admintool.md 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 + + + + + + +