Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 суток (регламент валют, разд. 3)

- CrystalTrader (без Unity) + 5 тестов из раздела 12
- Входит в недельный PvE-предел; нет квоты, места или покупок -- блок, золото не списывается
- Магазин: карточка «Редкий торговец»; DEV «Золото 100 000» для проверки

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-06 16:27:27 +01:00
co-authored by Claude Opus 5.5
parent 277afdf84e
commit b51ad41925
9 changed files with 288 additions and 3 deletions
+2
View File
@@ -80,6 +80,8 @@ public class DebugPanel : MonoBehaviour
// Недельный PvE-предел кристаллов: «+1 PvE-кристалл» выдаёт как настоящая награда (квота, место, компенсация)
var rowQ = UIKit.Row(card, 70, 10);
Btn(rowQ, "+1 PvE-кристалл", UIKit.Theme.Blue, () => { if (GameProgress.Instance != null) GameProgress.Instance.Grant(new[] { new RewardItem(ResourceType.Gems, 1) }); Refresh(); });
// Только для проверки торговца (30 000 / 60 000): золото ставится напрямую, мимо лимита склада
Btn(rowQ, "Золото 100 000", UIKit.Theme.Green, () => { if (ResourceManager.Instance != null) ResourceManager.Instance.SetResource(ResourceType.Gold, 100000); Refresh(); });
Btn(rowQ, "Сбросить квоту", UIKit.Theme.Gray, () => { if (GameProgress.Instance != null) GameProgress.Instance.DevResetCrystalQuota(); UIKit.Toast("Недельная квота кристаллов сброшена"); Refresh(); });
var row3 = UIKit.Row(card, 70, 10);
+3
View File
@@ -105,6 +105,9 @@ public class EconomyConfig : ScriptableObject
public int crystalPveWindowDays = 7;
[Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")]
public float crystalCompensationGold = 1000f;
[Tooltip("Редкий PvE-торговец: цена синего кристалла в золоте по порядку покупок за 7 реальных суток; покупок не больше, " +
"чем цен (регламент валют 05.11, разд. 3). Входит в недельный PvE-предел. ТЕСТ")]
public int[] crystalTraderPrices = { 30000, 60000 };
[Tooltip("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " +
"Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")]
public CrystalExpansion[] crystalExpansions =
+55
View File
@@ -150,6 +150,10 @@ public class GameProgress : MonoBehaviour
var storageCfg = EconomyConfig.Load().storage;
quota = new CrystalQuota(storageCfg.crystalPveLimit, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
quota.Load(Data.crystalQuota);
if (Data.crystalTrader == null) Data.crystalTrader = new List<string>();
if (Data.crystalTraderNextId < 1) Data.crystalTraderNextId = 1;
trader = new CrystalTrader(storageCfg.crystalTraderPrices, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
trader.Load(Data.crystalTrader);
if (Data.mail == null) Data.mail = new List<MailItem>();
if (Data.shopIds == null) Data.shopIds = new List<string>();
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
@@ -377,11 +381,62 @@ public class GameProgress : MonoBehaviour
return allowed;
}
// ---------------- Редкий PvE-торговец (правила -- CrystalTrader) ----------------
CrystalTrader trader = new CrystalTrader();
public int TraderPrice => trader.PriceNow(GameClock.UnixNowMs); // -1 -- в этом окне больше нельзя
public int TraderBought => trader.BoughtInWindow(GameClock.UnixNowMs);
public int TraderMaxPurchases => trader.Prices.Length;
public double TraderSecondsToSlot
{
get { long at = trader.NextSlotMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
}
// Почему сейчас нельзя купить (без списания); null -- можно, если хватит золота
public string TraderBlockReason()
{
switch (trader.Check(GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap)))
{
case CrystalTrader.Result.NoSlots: return $"Куплено {TraderBought} из {TraderMaxPurchases} за 7 дней. Следующая покупка -- через {GameData.FormatTime(TraderSecondsToSlot)}";
case CrystalTrader.Result.NoQuota: return $"Недельный предел новых кристаллов исчерпан ({quota.Limit} за 7 дней). Освободится через {GameData.FormatTime(CrystalQuotaSecondsToFree)}";
case CrystalTrader.Result.NoSpace: return $"Хранилище кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). Расширить -- в окне Ратуши";
default: return null;
}
}
// Купить 1 синий кристалл. Проверки (покупки, квота, место) -- до оплаты: при отказе золото не списывается
public void BuyCrystalFromTrader()
{
string id = "trader_" + Data.crystalTraderNextId;
var result = trader.TryBuy(id, GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap),
price => GameServices.TryGet(out IEconomy e) && e.TrySpend(ResourceType.Gold, price));
if (result != CrystalTrader.Result.Bought)
{
UIKit.Toast(result == CrystalTrader.Result.NoGold ? $"Нужно {TraderPrice:N0} золота" : (TraderBlockReason() ?? "Покупка недоступна"));
return;
}
Data.crystalTraderNextId++;
if (ResourceManager.Instance != null)
{
suppressTracking++;
ResourceManager.Instance.AddResource(ResourceType.Gems, 1); // место проверено до оплаты
suppressTracking--;
}
Data.crystalTrader = trader.ToLines();
Data.crystalQuota = quota.ToLines();
MarkChanged();
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // золото, кристалл и покупка -- сразу на диск
UIKit.Toast("Куплен 1 синий кристалл");
}
// Только для проверки (DEV): забыть недельную квоту
public void DevResetCrystalQuota()
{
quota.Load(null);
trader.Load(null);
Data.crystalQuota = quota.ToLines();
Data.crystalTrader = trader.ToLines();
MarkChanged();
}
+3
View File
@@ -73,6 +73,9 @@ public class ProgressData
public List<string> crystalWaiting = new List<string>();
// Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции"
public List<string> crystalQuota = new List<string>();
// Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки"
public List<string> crystalTrader = new List<string>();
public int crystalTraderNextId = 1;
public float[] dailyCollected = new float[8];
public int dailyBattles;
public int dailyTroops;
+28 -2
View File
@@ -1,8 +1,8 @@
using UnityEngine;
using TMPro;
// МАГАЗИН: бесплатный сундук раз в день, пакеты ресурсов за кристаллы, обмен золота,
// витрина платных наборов (пока "скоро"). Лимиты покупок сбрасываются в полночь.
// МАГАЗИН: редкий PvE-торговец (синий кристалл за золото), бесплатный сундук раз в день, пакеты ресурсов за золото.
// Лимиты пакетов сбрасываются в полночь; у торговца -- свои: не больше 2 кристаллов за 7 реальных суток.
public class ShopPanel : BasePanel
{
TextMeshProUGUI timer;
@@ -15,6 +15,8 @@ public class ShopPanel : BasePanel
WalletItem(wallet, ResourceType.Gems);
WalletItem(wallet, ResourceType.Gold);
TraderCard(list);
timer = UIKit.Text(list, "", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
Tick();
@@ -52,6 +54,30 @@ public class ShopPanel : BasePanel
}
}
// Редкий PvE-торговец (регламент валют 05.11, разд. 3): дорогой обмен золота на синий кристалл
void TraderCard(Transform list)
{
var gp = GameProgress.Instance;
int price = gp.TraderPrice;
string block = gp.TraderBlockReason();
bool enough = price >= 0 && gp.Res(ResourceType.Gold) >= price;
var card = UIKit.Card(list, UIKit.Theme.CardDark);
var row = UIKit.Row(card, 150, 16);
UIKit.Icon(row, GameData.ResColor(ResourceType.Gems), "", 110);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
var title = UIKit.Text(info, "Редкий торговец: синий кристалл", 30);
title.fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Куплено за 7 дней: {gp.TraderBought} / {gp.TraderMaxPurchases}. Входит в недельный предел новых кристаллов " +
$"({gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit})", 22, UIKit.Theme.TextDim);
if (block != null) UIKit.Text(info, block, 22, UIKit.Theme.Gold);
string label = price < 0 ? "Недоступно" : price.ToString("N0") + " золота";
var b = UIKit.Button(row, label, block == null && enough ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { gp.BuyCrystalFromTrader(); RequestRebuild(); }, 260, 100, 24);
b.interactable = block == null;
}
static void WalletItem(Transform parent, ResourceType t)
{
UIKit.Icon(parent, GameData.ResColor(t), "", 40);
@@ -0,0 +1,99 @@
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<Purchase> purchases = new List<Purchase>();
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<int, bool> 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<string> ToLines()
{
var lines = new List<string>();
foreach (var p in purchases) lines.Add(p.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + p.Id);
return lines;
}
public void Load(IEnumerable<string> 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] : "" });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c25a8e3f7d1b4c69b0e4a6f9d8c37b21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -584,3 +584,87 @@ public class CrystalQuotaTests
Assert.AreEqual(2, q.Used(T0 + 10), "квота потрачена один раз: 2, а не 4");
}
}
// Редкий PvE-торговец: синий кристалл за золото (регламент валют 05.11, разд. 3, 10, 12)
public class CrystalTraderTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static System.Func<int, bool> Wallet(float[] gold) => price =>
{
if (gold[0] < price) return false;
gold[0] -= price;
return true;
};
[Test]
public void Trader_Quota4of6_Buys_ChargesGold_CountsQuota_Max2()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 4, T0);
var t = new CrystalTrader(new[] { 30000, 60000 }, 7 * Day);
var gold = new[] { 100000f };
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "первая -- 30 000");
Assert.AreEqual(5, quota.Used(T0 + 1), "квота +1");
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b2", T0 + 2, quota, 10, Wallet(gold)));
Assert.AreEqual(10000f, gold[0], 0.01, "вторая -- 60 000");
Assert.AreEqual(CrystalTrader.Result.NoSlots, t.TryBuy("b3", T0 + 3, quota, 10, Wallet(gold)), "третьей нет");
Assert.AreEqual(10000f, gold[0], 0.01, "золото не списано");
}
[Test]
public void Trader_Quota6of6_Blocked_NoGoldSpent()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 6, T0);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoQuota, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(50000f, gold[0], 0.01, "6/6 + покупка -> блок без расхода золота");
Assert.AreEqual(0, t.BoughtInWindow(T0 + 1));
}
[Test]
public void Trader_NoSpace_Or_NoGold_NothingChanges()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoSpace, t.TryBuy("b1", T0, quota, 0, Wallet(gold)), "хранилище полное");
Assert.AreEqual(50000f, gold[0], 0.01);
var poor = new[] { 1000f };
Assert.AreEqual(CrystalTrader.Result.NoGold, t.TryBuy("b2", T0, quota, 10, Wallet(poor)), "не хватает золота");
Assert.AreEqual(0, quota.Used(T0), "квота не тронута");
Assert.AreEqual(30000, t.PriceNow(T0), "цена по-прежнему первая");
}
[Test]
public void Trader_SameOperationTwice_OnePurchase()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 100000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
Assert.AreEqual(CrystalTrader.Result.Repeated, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "повтор той же покупки -- ничего не списано");
Assert.AreEqual(1, quota.Used(T0 + 1));
}
[Test]
public void Trader_WindowRolls_AndSurvivesSaveLoad()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 1000000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
t.TryBuy("b2", T0 + Day, quota, 10, Wallet(gold));
var copy = new CrystalTrader(null, 7 * Day);
copy.Load(t.ToLines());
Assert.AreEqual(-1, copy.PriceNow(T0 + 2 * Day), "после перезапуска -- те же 2 покупки");
Assert.AreEqual(T0 + 7 * Day, copy.NextSlotMs(T0 + 2 * Day));
Assert.AreEqual(60000, copy.PriceNow(T0 + 7 * Day), "первая покупка вышла из окна -- следующая снова вторая по счёту");
Assert.AreEqual(30000, copy.PriceNow(T0 + 8 * Day), "обе вышли из окна -- снова 30 000");
}
}