using System; using System.Collections.Generic; using System.Globalization; // НЕДЕЛЬНЫЙ PvE-ПРЕДЕЛ СИНИХ КРИСТАЛЛОВ (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 5.2, 10, 12; // «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы». // // - Не больше Limit (ТЕСТ 6) НОВЫХ синих кристаллов за скользящие WindowMs (7 реальных суток) из PvE: задания, // события, бои, PvE-торговец. Кристальная шахта, торговля и грабёж -- вне предела. // - Take: сколько из награды можно выдать кристаллами сейчас; остальное вызывающий заменяет компенсацией // (золото / ресурсы). На следующую неделю излишек не переносится. // - Квота и место в хранилище -- разные условия: взятое по квоте считается сразу, даже если кристаллы потом // ждут места, -- второй раз квота за них не тратится. // - Операция с тем же id второй раз квоту не берёт (повторное «Забрать», загрузка). // - Время -- метки (мс), окно скользящее: запись старше окна больше не считается. На сервере -- доверенное время. // Без Unity: те же правила будут на сервере и в тестах. public sealed class CrystalQuota { public const long Day = 24L * 60 * 60 * 1000; public readonly int Limit; public readonly long WindowMs; struct Entry { public long TimeMs; public int Amount; public string Id; } readonly List entries = new List(); public CrystalQuota(int limit = 6, long windowMs = 7 * Day) { Limit = Math.Max(0, limit); WindowMs = Math.Max(1, windowMs); } // Сколько новых PvE-кристаллов уже выдано за окно, кончающееся в nowMs public int Used(long nowMs) { int n = 0; foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs <= nowMs) n += e.Amount; return n; } public int Left(long nowMs) => Math.Max(0, Limit - Used(nowMs)); // Когда освободится хотя бы один кристалл квоты (мс); 0 -- уже свободно public long NextFreeMs(long nowMs) { if (Left(nowMs) > 0) return 0; long first = long.MaxValue; foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs < first) first = e.TimeMs; return first == long.MaxValue ? 0 : first + WindowMs; } public bool Taken(string id) => !string.IsNullOrEmpty(id) && entries.Exists(e => e.Id == id); // Взять квоту под награду: сколько из amount выдать кристаллами (остальное -- компенсацией). // Тот же id второй раз -- 0 (уже учтено). Записи старше окна убираются public int Take(string id, int amount, long nowMs) { Prune(nowMs); if (amount <= 0 || Taken(id)) return 0; int give = Math.Min(amount, Left(nowMs)); if (give > 0 || !string.IsNullOrEmpty(id)) entries.Add(new Entry { TimeMs = nowMs, Amount = give, Id = id ?? "" }); return give; } // Можно ли взять amount целиком (покупка у PvE-торговца: при нехватке -- блок, золото не списывается) public bool CanTakeAll(int amount, long nowMs) => amount <= Left(nowMs); void Prune(long nowMs) => entries.RemoveAll(e => e.TimeMs <= nowMs - WindowMs); // ---------------- Сохранение: строки "время|сколько|id" ---------------- public List ToLines() { var lines = new List(); foreach (var e in entries) lines.Add(e.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + e.Amount.ToString(CultureInfo.InvariantCulture) + "|" + e.Id); return lines; } public void Load(IEnumerable lines) { entries.Clear(); if (lines == null) return; foreach (var line in lines) { if (string.IsNullOrEmpty(line)) continue; var p = line.Split(new[] { '|' }, 3); if (p.Length < 2) continue; if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue; if (!int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) || n < 0) continue; entries.Add(new Entry { TimeMs = t, Amount = n, Id = p.Length > 2 ? p[2] : "" }); } } }