using System; using System.Collections.Generic; using System.Globalization; // РЕДКИЙ PvE-ТОРГОВЕЦ: синий кристалл за золото (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 3, 10, 12; // «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы». // // - За скользящие 7 реальных суток: 1-я покупка -- Prices[0] (ТЕСТ 30 000 золота), 2-я -- Prices[1] (60 000), // дальше -- недоступно (не больше Prices.Length покупок). // - Покупка входит в недельный PvE-предел (CrystalQuota). Нет квоты -- покупка заблокирована, золото НЕ списывается, // компенсации за оплаченный товар нет. // - Нет места в хранилище -- тоже блок без списания (вместимость получателя проверяется до оплаты). // - Порядок: все проверки -> списание золота (всё или ничего) -> квота -> запись покупки. Одна операция -- один id, // повтор того же id ничего не делает. // Без Unity: те же правила будут на сервере и в тестах. public sealed class CrystalTrader { public enum Result { Bought, NoSlots, NoQuota, NoSpace, NoGold, Repeated } public readonly int[] Prices; public readonly long WindowMs; struct Purchase { public long TimeMs; public string Id; } readonly List purchases = new List(); public CrystalTrader(int[] prices = null, long windowMs = 7 * CrystalQuota.Day) { Prices = prices != null && prices.Length > 0 ? prices : new[] { 30000, 60000 }; WindowMs = Math.Max(1, windowMs); } public int BoughtInWindow(long nowMs) { int n = 0; foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs <= nowMs) n++; return n; } // Цена следующей покупки; -1 -- в этом окне больше нельзя public int PriceNow(long nowMs) { int n = BoughtInWindow(nowMs); return n < Prices.Length ? Prices[n] : -1; } // Когда снова можно купить (мс), если покупки кончились; 0 -- можно сейчас public long NextSlotMs(long nowMs) { if (PriceNow(nowMs) >= 0) return 0; long first = long.MaxValue; foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs < first) first = p.TimeMs; return first == long.MaxValue ? 0 : first + WindowMs; } // Что помешает купить сейчас (без списания); Bought -- можно public Result Check(long nowMs, CrystalQuota quota, int freeSpace) { if (PriceNow(nowMs) < 0) return Result.NoSlots; if (quota != null && !quota.CanTakeAll(1, nowMs)) return Result.NoQuota; if (freeSpace < 1) return Result.NoSpace; return Result.Bought; } // Купить 1 синий кристалл. trySpendGold(цена) -- списание «всё или ничего». При Bought кристалл начисляет вызывающий public Result TryBuy(string id, long nowMs, CrystalQuota quota, int freeSpace, Func trySpendGold) { if (!string.IsNullOrEmpty(id) && purchases.Exists(p => p.Id == id)) return Result.Repeated; var check = Check(nowMs, quota, freeSpace); if (check != Result.Bought) return check; int price = PriceNow(nowMs); if (trySpendGold == null || !trySpendGold(price)) return Result.NoGold; if (quota != null) quota.Take(id, 1, nowMs); purchases.RemoveAll(p => p.TimeMs <= nowMs - WindowMs); purchases.Add(new Purchase { TimeMs = nowMs, Id = id ?? "" }); return Result.Bought; } // ---------------- Сохранение: строки "время|id" ---------------- public List ToLines() { var lines = new List(); foreach (var p in purchases) lines.Add(p.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + p.Id); return lines; } public void Load(IEnumerable lines) { purchases.Clear(); if (lines == null) return; foreach (var line in lines) { if (string.IsNullOrEmpty(line)) continue; var p = line.Split(new[] { '|' }, 2); if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue; purchases.Add(new Purchase { TimeMs = t, Id = p.Length > 1 ? p[1] : "" }); } } }