From b51ad419256f625ca5e5279cfbf7d7eccee26bd3 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:27:27 +0100 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=BA=D0=B8=D0=B9=20PvE-?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B3=D0=BE=D0=B2=D0=B5=D1=86:=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=B9=20=D0=BA=D1=80=D0=B8=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BB=20=D0=B7=D0=B0=2030=20000=20/=2060=20000=20=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D1=82=D0=B0,=20=D0=BD=D0=B5=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=202=20=D0=B7=D0=B0=207=20=D1=81=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=20(=D1=80=D0=B5=D0=B3=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82,=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B4.=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CrystalTrader (без Unity) + 5 тестов из раздела 12 - Входит в недельный PvE-предел; нет квоты, места или покупок -- блок, золото не списывается - Магазин: карточка «Редкий торговец»; DEV «Золото 100 000» для проверки Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/App/DebugPanel.cs | 2 + Assets/Scripts/App/EconomyConfig.cs | 3 + Assets/Scripts/Meta/Core/GameProgress.cs | 55 +++++++++++ Assets/Scripts/Meta/Core/ProgressData.cs | 3 + Assets/Scripts/Meta/Panels/ShopPanel.cs | 30 +++++- .../Modules/Economy/Rules/CrystalTrader.cs | 99 +++++++++++++++++++ .../Economy/Rules/CrystalTrader.cs.meta | 11 +++ .../Scripts/Modules/Tests/ProductionTests.cs | 84 ++++++++++++++++ Docs/Modules/Economy.md | 4 +- 9 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs.meta diff --git a/Assets/Scripts/App/DebugPanel.cs b/Assets/Scripts/App/DebugPanel.cs index e8af42e..312d52e 100644 --- a/Assets/Scripts/App/DebugPanel.cs +++ b/Assets/Scripts/App/DebugPanel.cs @@ -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); diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index cc1d7af..a5bc438 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -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 = diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index c954ab9..fc16104 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -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(); + 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(); if (Data.shopIds == null) Data.shopIds = new List(); if (Data.shopCounts == null) Data.shopCounts = new List(); @@ -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(); } diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index d911872..d8d8432 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -73,6 +73,9 @@ public class ProgressData public List crystalWaiting = new List(); // Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции" public List crystalQuota = new List(); + // Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки" + public List crystalTrader = new List(); + public int crystalTraderNextId = 1; public float[] dailyCollected = new float[8]; public int dailyBattles; public int dailyTroops; diff --git a/Assets/Scripts/Meta/Panels/ShopPanel.cs b/Assets/Scripts/Meta/Panels/ShopPanel.cs index 97411bb..743e8b4 100644 --- a/Assets/Scripts/Meta/Panels/ShopPanel.cs +++ b/Assets/Scripts/Meta/Panels/ShopPanel.cs @@ -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); diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs b/Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs new file mode 100644 index 0000000..f7d4942 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs @@ -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 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] : "" }); + } + } +} diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs.meta b/Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs.meta new file mode 100644 index 0000000..e808a2b --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalTrader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c25a8e3f7d1b4c69b0e4a6f9d8c37b21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Tests/ProductionTests.cs b/Assets/Scripts/Modules/Tests/ProductionTests.cs index 699d2ca..754f855 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -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 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"); + } +} diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 786ca07..2993a1f 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -101,10 +101,11 @@ Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 50 и 200), письмо-приветствие (было 100), подарок за уровень (было 10 × уровень), арена (было 5), этапы боя (было 25 на каждом 5-м), бесплатный сундук (было 15). Вместо них — золото и ресурсы. - **Хранилище — платное расширение (разд. 8 регламента валют, ответ 06.11, п. 1).** Старт 50; ступени (ТЕСТ): Ратуша 3 — 75 (2000 золота, 1500 дерева, 1500 камня), Ратуша 5 — 100 (4000 / 3000 / 3000 + 1000 металла), Ратуша 7 — 150 (7000 / 5000 / 5000 / 2000), Ратуша 9 — 200 (10000 / 8000 / 8000 / 4000). Ратуша только разрешает ступень, покупается в окне Ратуши («Хранилище синих кристаллов» → «Расширить до N»). Цена списывается один раз, всё или ничего. Правила — `Modules/Economy/Rules/CrystalStorage.cs`, тесты — `CrystalStorageTests`; числа — `EconomyConfig` → «Склады и лимиты» → `crystalCap`, `crystalExpansions`. Купленная ступень сохраняется в секции `"economy"` (`crystalStorageLevel`; в старом сохранении её нет — 0), вместимость по ней считается заново. - **Недельный PvE-предел (разд. 5.2, 10, 12 регламента валют; ответ 06.11, п. 2).** Не больше 6 новых синих кристаллов за скользящие 7 реальных суток из заданий, событий и боёв (позже — PvE-торговец). Сверх квоты — 1000 золота за кристалл (компенсация не согласована, ТЕСТ), на следующую неделю не переносится; при частичной квоте — часть кристаллами, остальное золотом. Квота и место в хранилище — разные условия: взятое по квоте, но не поместившееся ждёт места и квоту второй раз не тратит; кристаллы из письма «Кристаллы ждут места» квоту не берут. Задание берёт квоту один раз (по id). Видно в окне Ратуши: «Новых кристаллов из заданий, событий и боёв за 7 дней: N / 6». Правила — `Modules/Economy/Rules/CrystalQuota.cs`, тесты — `CrystalQuotaTests`; числа — `EconomyConfig` → `crystalPveLimit`, `crystalPveWindowDays`, `crystalCompensationGold`; сохранение — `progress.json`, `crystalQuota`. DEV: «+1 PvE-кристалл», «Сбросить квоту». +- **Редкий PvE-торговец (разд. 3 и 10 регламента валют).** Магазин → «Редкий торговец: синий кристалл»: за скользящие 7 реальных суток первая покупка — 30 000 золота, вторая — 60 000, третьей нет (`EconomyConfig` → `crystalTraderPrices`, ТЕСТ). Покупка входит в недельный PvE-предел. Все проверки — до оплаты: нет покупок в окне, нет квоты или нет места в хранилище — блок, золото не списывается, компенсации нет. Одна покупка — один id, повтор ничего не делает; после покупки игра сразу сохраняется. Правила — `Modules/Economy/Rules/CrystalTrader.cs`, тесты — `CrystalTraderTests`; сохранение — `progress.json`, `crystalTrader`. DEV: «Золото 100 000» (мимо лимита склада, для проверки), «Сбросить квоту» сбрасывает и покупки. **Вопрос:** при лимите золота 1000 + 1000 за уровень Склада цены 30 000 / 60 000 почти недостижимы. - **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс. - **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов. -**Ещё не сделано:** редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. +**Ещё не сделано:** Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. **Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты. @@ -129,6 +130,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 реальных суток, в пределах недельной квоты; блок без списания (`CrystalTrader` + 5 тестов, магазин, DEV «Золото 100 000»). | | 06.11.2026 | Claude (Саша) | Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх — 1000 золота за кристалл (ТЕСТ); `CrystalQuota` + 6 тестов, `GameProgress.Grant/ClaimQuest`, окно Ратуши, DEV-кнопки. | | 06.11.2026 | Claude (Саша) | Ускорения стройки, улучшения и обучения войск — за золото: 1 золото за 10 с (`secondsPerGold`, ТЕСТ), было 1 кристалл за 30 с. | | 06.11.2026 | Claude (Саша) | Хранилище синих кристаллов: платное расширение 50 → 75 / 100 / 150 / 200 на Ратуше 3 / 5 / 7 / 9 в окне Ратуши (`CrystalStorage`, `EconomyConfig.crystalExpansions`, `ResourceManager.CrystalStorageLevel` в сохранении, 5 тестов). Вместимость кристаллов больше не константа 50. |