Small dynaminc pricing persistence issue

If you create a Monument Addons profile containing a vending machine and don't customize it, it will be populated as Output Outfitters (the clothing machine from Outpost) by default.

It seems that dynamic pricing data for this setup doesn't get persisted, because there's no explicit inventory in the profile.

Workaround is (hopefully) to explicitly populate the inventory, e.g. pull 1 scrap from one of the items, save, put the scrap back, save.

This may be caused by the following check.
https://github.com/WheteThunger/CustomVendingSetup/blob/master/CustomVendingSetup.cs#L2860-L2862

However, I'm having trouble understanding this issue. If the vending machine isn't customized, then Custom Vending Setup (CVS) theoretically shouldn't be expected to solve this (CVS might not even be installed), meaning it would instead be an issue/limitation with Monument Addons.

If the vending machine sale offers aren't customized, then I presume we should be able to rely on vanilla saving of sales data, as long as the vending machines are being persisted across server restarts via the Monument Addons configuration for saving entities. I wonder if the plugin is somehow overriding that for these vending machines.

Not sure. For additional context: I have a custom MA profile that adds all Outpost amenities to Bandit Camp, including custom copies of all the vending machines. Since the default vending machine is one of those, I figured I shouldn't need to customize it - but then a player reported that dynamic pricing was stuck at +80% for that vending machine, and I confirmed it.

The Fish Exchange in all fishing villages on my server is always -50%, even though I haven't changed anything for these shops. This has ruined the wipe for many of my players.

That's weird, I've definitely used the fish exchange on my server this wipe and it was at +100%.

uoam

The Fish Exchange in all fishing villages on my server is always -50%, even though I haven't changed anything for these shops. This has ruined the wipe for many of my players.

If you haven't changed anything for those vending machines, then it sounds to me like you did not customize the vending machine with Custom Vending Setup at all. If so, then this plugin will not affect it, meaning the root cause of your issue is something else.

I disabled Custom Vending Setup yesterday, and over the past 24 hours, prices in the fishing villages have started to recover. This is the first time they've actually changed since the wipe.

Merged post

I need to check on a clean server; perhaps I changed the cooldown for selling a few months ago and forgot about it. I don't remember what the default values ​​should be. There are too many changes on the modified server to remember this.

Merged post

Despite these issues with the plugin, I'm grateful to you, WhiteThunder. I use many of your plugins and admire people like you who make and maintain them for free ❤️

I had to decompile Assembly-CSharp.dll to understand how dynamic pricing works. I tested it on my live server and, it seems, now everything works correctly.

The price recalculation tick is HourCheck, not the assortment refresh. The multiplier recalculation is done by the NPCVendingMachine.HourCheck method. It is hooked onto a repeating call in DynamicPricingServerInit:

InvokeRandomized(HourCheck, 1f, 15f, 0.1f); // runs roughly every 15 seconds

NPCVendingMachine.ServerInit calls DynamicPricingServerInit, and InvisibleVendingMachine.ServerInit calls the base ServerInit - which means HourCheck works for "invisible" machines too (the fish exchange, water well merchants).

HourCheck itself accumulates real time and, when an interval has been reached, runs SalesData.ProcessEndOfInterval() for each order:

void HourCheck() {
    if (!CanApplyDynamicPricing) return;          // = !BypassDynamicPricing && DynamicPricingEnabled
    float elapsed = (TimeSince)lastHourCheck;
    lastHourCheck = 0f;
    timeToNextSalesUpdate -= elapsed;
    while (timeToNextSalesUpdate < 0f) {
        timeToNextSalesUpdate += IntervalSeconds;
        if (allSalesData != null)
            foreach (var s in allSalesData) s.ProcessEndOfInterval();
        UpdateMapMarker(false);
        // network state update
    }
}

And InvisibleVendingMachine.CheckSellOrderRefresh internally only does the assortment regeneration and has nothing to do with the price:

void CheckSellOrderRefresh() {
    if ((TimeUntil)nextOrderRefresh < 0f) {
        nextOrderRefresh = waterWellNpcSalesRefreshFrequency * 3600f;
        InstallFromVendingOrders();
    }
}

The step formula SalesData.ProcessEndOfInterval:

void ProcessEndOfInterval() {
    double avgBefore = GetAverageSalesPerInterval();   // TotalSales / TotalIntervals (0 if TotalIntervals == 0)
    bool first = (TotalIntervals == 0);
    TotalSales += SoldThisInterval;
    TotalIntervals += 1;
    SoldThisInterval = 0;
    double avgAfter = GetAverageSalesPerInterval();
    float delta = (avgAfter > avgBefore && !first) ? PriceIncreaseAmount : -PriceDecreaseAmount;
    if (IsForReceivedCurrency) CurrentMultiplier -= delta;  // "buy" offers (the machine sells scrap)
    else                       CurrentMultiplier += delta;  // normal offers (purchase for scrap)
    CurrentMultiplier = Mathf.Clamp(CurrentMultiplier, MinimumPriceMultiplier, MaximumPriceMultiplier);
}

The multiplier rises only when the moving average of sales rises, otherwise it decreases by PriceDecreaseAmount each interval and hits the lower limit.

Array initialization - CheckSalesDataLength:

void CheckSalesDataLength(bool reset) {
    if (reset) allSalesData = null;
    int count = sellOrders.sellOrders.Count;
    if (allSalesData != null && allSalesData.Length == count) return; // lengths match - we exit
    allSalesData = new SalesData[count];                              // otherwise we REBUILD the array
    for (int i = 0; i < count; i++) {
        bool isScrap = sellOrders.sellOrders[i].itemToSellID == ScrapItem.itemid;
        allSalesData[i] = new SalesData {
            IsForReceivedCurrency = isScrap,
            CurrentMultiplier = isScrap ? MinimumPriceMultiplier    // 0.5  → −50%
                                        : StartingPriceMultiplier   // 2.0  → +100%
        };
    }
}

That is, for "buy" offers (the machine sells scrap: the fish exchange, etc.) the starting and lower value of the multiplier is MinimumPriceMultiplier = 0.5, meaning −50% is built in as the default by design.

The price the player sees is read from allSalesData[slot].CurrentMultiplier through GetDiscountForSlot and GetReceivedQuantityMultiplier. And both functions are arranged like this:

if (!CanApplyDynamicPricing) return 1f;
if (!preserveSalesData) CheckSalesDataLength(false);   // The key thing
...
return allSalesData[slot].CurrentMultiplier;

As long as the preserveSalesData flag == false, these getters pull CheckSalesDataLength on every update, which rebuilds allSalesData as soon as its length does not match the number of orders - and resets the multipliers to the default (for buy offers - to those very 0.5).

  1. For buy offers the lower/starting value of the multiplier is MinimumPriceMultiplier = 0.5 (−50%). It can rise only when the moving average of sales rises.
  2. Vanilla freely rebuilds the array. The multiplier getters, when preserveSalesData == false, call CheckSalesDataLength, which resets the multipliers to the default on any mismatch between allSalesData.Length and the number of orders. The plugin never sets this flag.
  3. The plugin provokes the length mismatch. In the VendingMachineState.ApplyToVendingMachine method the array is restored with length min(saved, current) through .Take(sellOrders.sellOrders.Count). If there are fewer saved entries than offers (the profile was edited, an offer was added/reordered, or the state was saved before a change), a persistent length mismatch arises.

In sum: vanilla on every refresh sees the length mismatch and resets the buy offers back to 0.5, overwriting the multipliers restored by the plugin. They do not have time to accumulate a rising average, and the shop is stuck permanently at −50%. The plugin meanwhile honestly computes the multiplier and even records sales through RecordSale, but this does not help, because the history is periodically zeroed out by vanilla's CheckSalesDataLength.

I also learned that there is a natural price decline toward the end of the wipe (because of the cumulative average) - this is separate, intended behavior of the game.

Fix

Idea: make the plugin the sole owner of allSalesData and not let vanilla rebuild it. For this the game has a public flag NPCVendingMachine.preserveSalesData - it is intended precisely for this.

Three edits need to be made:

Edit 1 - take ownership of the data after restoration

Was:

                Plugin._salesData.FindState(_vendingMachine)?.ApplyToVendingMachine(_vendingMachine);

                FixDynamicSalesDirection();
            }

Became:

                Plugin._salesData.FindState(_vendingMachine)?.ApplyToVendingMachine(_vendingMachine);

                FixDynamicSalesDirection();

                _vendingMachine.preserveSalesData = true;
            }

Edit 2 - hand control back to vanilla on reset

Was:

            private void ResetToVanilla()
            {
                CancelInvoke(TimedRefill);

Became:

            private void ResetToVanilla()
            {
                CancelInvoke(TimedRefill);

                _vendingMachine.preserveSalesData = false;

Edit 3 - build the array strictly by the number of orders (without .Take)

Was:

            public void ApplyToVendingMachine(NPCVendingMachine vendingMachine)
            {
                var salesData = SalesData?.Select(data => data.ToVendingMachineSalesData())
                        .Take(vendingMachine.sellOrders.sellOrders.Count)
                        .ToArray() ?? Array.Empty<NPCVendingMachine.SalesData>();

                vendingMachine.allSalesData = salesData;
            }

Became:

            public void ApplyToVendingMachine(NPCVendingMachine vendingMachine)
            {
                var orderCount = vendingMachine.sellOrders.sellOrders.Count;
                var savedLength = SalesData?.Length ?? 0;
                var salesData = new NPCVendingMachine.SalesData[orderCount];

                for (var i = 0; i < orderCount; i++)
                {
                    salesData[i] = i < savedLength
                        ? SalesData[i].ToVendingMachineSalesData()
                        : new NPCVendingMachine.SalesData { CurrentMultiplier = 1f };
                }

                vendingMachine.allSalesData = salesData;
            }

Why all three are needed

  • Edit 1 removes the main reset: the multiplier getters stop calling CheckSalesDataLength.
  • Edit 3 safeguards the remaining path: RecordSale calls CheckSalesDataLength(false) regardless of preserveSalesData, so the array length must always match the number of orders, otherwise the reset will happen on the very first sale. With an exact length this call becomes safe (it rebuilds nothing).
  • Edit 2 correctly hands pricing back to vanilla when the customization is removed or the plugin is unloaded.

In sum: the length of allSalesData is always equal to the number of offers, vanilla does not rebuild the array and does not drop the buy offers to 0.5, and HourCheck/ProcessEndOfInterval move the multiplier according to actual sales. The buy shops properly rise from −50% as trading goes on.

If I understand correctly, you are saying these are the reproduction steps.

  1. Customize a vending machine with N sale offers
  2. Make a purchase (potentially resetting sales data)
  3. Customize the same vending machine again, this time adding an item so there are N+1 sale offers
  4. Reload the plugin (restoring sales data corresponding to N sale offers)
  5. Make a purchase --> Sales data gets reset because N != N+1
Is that correct? If so, it seems this would not happen under normal operation, it would happen only if admins are frequently editing vending machines. Am I missing something?