Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх -- золото (регламент валют 05.11, ответ 06.11)

- CrystalQuota (без Unity) + 6 тестов из раздела 12
- Задания, события, бои: в пределах квоты -- кристаллы, сверх -- 1000 золота за кристалл (ТЕСТ)
- Ждущие места кристаллы квоту второй раз не тратят; письмо «ждут места» -- вне квоты
- Окно Ратуши: «Новых кристаллов за 7 дней: N / 6»; DEV: «+1 PvE-кристалл», «Сбросить квоту»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-06 16:24:16 +01:00
co-authored by Claude Opus 5.5
parent e05cf87173
commit 277afdf84e
9 changed files with 267 additions and 12 deletions
@@ -0,0 +1,94 @@
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<Entry> entries = new List<Entry>();
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<string> ToLines()
{
var lines = new List<string>();
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<string> 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] : "" });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e1f4c7a2b6d48a3a8c5f0d2e7b39c14
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -514,3 +514,73 @@ public class CrystalStorageTests
Assert.AreEqual(0, r.Left("q"), "больше ничего не ждёт -- выдан ровно один раз");
}
}
// Недельный PvE-предел синих кристаллов (регламент валют 05.11, разд. 5.2, 10, 12; ответ 06.11, п. 2)
public class CrystalQuotaTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
[Test]
public void Quota_5of6_Reward2_OneCrystal_OneCompensated()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(5, q.Take("a", 5, T0));
Assert.AreEqual(1, q.Take("b", 2, T0 + 1000), "5/6 + 2 -> 1 кристалл (второй -- компенсацией)");
Assert.AreEqual(0, q.Left(T0 + 1000));
}
[Test]
public void Quota_6of6_Purchase_Blocked()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.IsFalse(q.CanTakeAll(1, T0 + 1), "6/6 -- покупка у торговца блокируется (золото не списывается)");
Assert.AreEqual(0, q.Take("b", 1, T0 + 1));
}
[Test]
public void Quota_SameOperationTwice_CountedOnce()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(2, q.Take("event_5", 2, T0));
Assert.AreEqual(0, q.Take("event_5", 2, T0 + 5), "повторное «Забрать» -- квота не берётся второй раз");
Assert.AreEqual(2, q.Used(T0 + 5));
}
[Test]
public void Quota_RollingWindow_FreesAfter7Days_NoCarryOver()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.AreEqual(0, q.Left(T0 + 7 * Day - 1), "ещё не прошло 7 суток");
Assert.AreEqual(T0 + 7 * Day, q.NextFreeMs(T0 + Day));
Assert.AreEqual(6, q.Left(T0 + 7 * Day), "через 7 суток квота снова 6, а не 6 + перенос");
Assert.AreEqual(6, q.Take("b", 9, T0 + 7 * Day), "сверх 6 -- компенсацией, на следующую неделю не переносится");
}
[Test]
public void Quota_SurvivesSaveAndLoad()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 4, T0);
var copy = new CrystalQuota(6, 7 * Day);
copy.Load(q.ToLines());
Assert.AreEqual(2, copy.Left(T0 + Day), "после перезапуска -- те же 4 из 6");
Assert.IsTrue(copy.Taken("a"));
}
[Test]
public void Quota_SeparateFromStorage_WaitingNotCountedTwice()
{
// Квота есть, а места нет: выданное по квоте ждёт места и второй раз квоту не тратит
var q = new CrystalQuota(6, 7 * Day);
var r = new CrystalRewards();
int byQuota = q.Take("q1", 2, T0);
Assert.AreEqual(2, byQuota);
Assert.AreEqual(0, r.Claim("q1", byQuota, 50, 50), "хранилище 50/50 -- ничего не начислено");
Assert.AreEqual(2, r.Left("q1"), "2 ждут места");
Assert.AreEqual(2, r.Claim("q1", 0, 40, 50), "место появилось -- выданы ждущие");
Assert.AreEqual(2, q.Used(T0 + 10), "квота потрачена один раз: 2, а не 4");
}
}