diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index d3b327b..0175b74 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -336,6 +336,15 @@ public static class BuildingWindow float have = GameServices.TryGet(out IEconomy e) ? e.Get(ResourceType.Gems) : 0f; return $"Кристаллов {have:0} / {EconomyConfig.CrystalStorageNow().Capacity}"; }, 24, UIKit.Theme.Text); + // Недельный PvE-предел (регламент валют 05.11, ответ 06.11): сколько новых кристаллов из заданий, событий и боёв + Live(card, () => + { + var gp = GameProgress.Instance; + if (gp == null) return ""; + string text = $"Новых кристаллов из заданий, событий и боёв за 7 дней: {gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit}"; + if (gp.CrystalQuotaLeft <= 0) text += $". Квота освободится через {GameData.FormatTime(gp.CrystalQuotaSecondsToFree)}, до тех пор -- золото"; + return text; + }, 22, UIKit.Theme.TextDim); var next = storage.Next; string why = storage.WhyNot(townHallLevel); diff --git a/Assets/Scripts/App/DebugPanel.cs b/Assets/Scripts/App/DebugPanel.cs index 920738f..e8af42e 100644 --- a/Assets/Scripts/App/DebugPanel.cs +++ b/Assets/Scripts/App/DebugPanel.cs @@ -77,6 +77,11 @@ public class DebugPanel : MonoBehaviour Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000)); Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems); + // Недельный 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(); }); + Btn(rowQ, "Сбросить квоту", UIKit.Theme.Gray, () => { if (GameProgress.Instance != null) GameProgress.Instance.DevResetCrystalQuota(); UIKit.Toast("Недельная квота кристаллов сброшена"); Refresh(); }); + var row3 = UIKit.Row(card, 70, 10); Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood); Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); }); diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index f42f317..cc1d7af 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -98,6 +98,13 @@ public class EconomyConfig : ScriptableObject public float perGranaryLevel = 1500f; [Tooltip("Стартовая вместимость синих кристаллов (регламент валют 05.11, разд. 8). ТЕСТ")] public float crystalCap = 50f; + [Tooltip("Недельный PvE-предел: не больше стольких НОВЫХ синих кристаллов за окно из заданий, событий, боёв, PvE-торговца. " + + "Шахта, торговля и грабёж -- вне предела (регламент валют 05.11, разд. 5.2; ответ 06.11, п. 2). ТЕСТ")] + public int crystalPveLimit = 6; + [Tooltip("Окно недельного PvE-предела, реальных суток (скользящее). ТЕСТ")] + public int crystalPveWindowDays = 7; + [Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")] + public float crystalCompensationGold = 1000f; [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 0f90f31..c954ab9 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -146,6 +146,10 @@ public class GameProgress : MonoBehaviour if (Data.claimedDaily == null) Data.claimedDaily = new List(); if (Data.crystalWaiting == null) Data.crystalWaiting = new List(); crystals.Load(Data.crystalWaiting); + if (Data.crystalQuota == null) Data.crystalQuota = new List(); + var storageCfg = EconomyConfig.Load().storage; + quota = new CrystalQuota(storageCfg.crystalPveLimit, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day); + quota.Load(Data.crystalQuota); if (Data.mail == null) Data.mail = new List(); if (Data.shopIds == null) Data.shopIds = new List(); if (Data.shopCounts == null) Data.shopCounts = new List(); @@ -309,13 +313,24 @@ public class GameProgress : MonoBehaviour return true; } - // Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят - // письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется - public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true) + // Выдать награду. Синие кристаллы (регламент валют 05.11, ответ 06.11): + // - новые PvE-кристаллы (newPveCrystals) -- в пределах недельной квоты; сверх неё -- золото (компенсация); + // - не сверх вместимости хранилища: не поместившиеся приходят письмом «Кристаллы ждут места» и забираются из него, + // когда место появится (квота за них уже учтена). Ничего не теряется. + // Возвращает, что выдано сейчас (кристаллы -- сколько поместилось, плюс компенсация) + public RewardItem[] Grant(RewardItem[] rewards, int xp = 0, bool toast = true, bool newPveCrystals = true) { - int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit; + int gems = GemsIn(rewards), compensated = 0, compGold = 0; + if (gems > 0 && newPveCrystals) + { + int allowed = TakeQuota(null, gems, out compGold); + compensated = gems - allowed; + gems = allowed; + } + int fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit; var given = new List(WithoutGems(rewards)); if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit)); + if (compGold > 0) given.Add(new RewardItem(ResourceType.Gold, compGold)); if (ResourceManager.Instance != null) { suppressTracking++; @@ -332,11 +347,47 @@ public class GameProgress : MonoBehaviour string text = GameData.RewardsText(given.ToArray()); if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта"; if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте"; + if (compensated > 0) text += (text.Length > 0 ? ". " : "") + QuotaText(compensated, compGold); if (text.Length > 0) UIKit.Toast("Получено: " + text); } MarkChanged(); + return given.ToArray(); } + // ---------------- Недельный PvE-предел синих кристаллов (правила -- CrystalQuota) ---------------- + + CrystalQuota quota = new CrystalQuota(); + + public int CrystalQuotaUsed => quota.Used(GameClock.UnixNowMs); + public int CrystalQuotaLimit => quota.Limit; + public int CrystalQuotaLeft => quota.Left(GameClock.UnixNowMs); + // Через сколько секунд освободится квота (0 -- уже есть) + public double CrystalQuotaSecondsToFree + { + get { long at = quota.NextFreeMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; } + } + + // Взять квоту: сколько из amount выдать кристаллами; за остальные -- золото (compGold). id -- операция (null -- разовая) + int TakeQuota(string id, int amount, out int compGold) + { + int allowed = quota.Take(id, amount, GameClock.UnixNowMs); + compGold = Mathf.RoundToInt((amount - allowed) * EconomyConfig.Load().storage.crystalCompensationGold); + Data.crystalQuota = quota.ToLines(); + MarkChanged(); + return allowed; + } + + // Только для проверки (DEV): забыть недельную квоту + public void DevResetCrystalQuota() + { + quota.Load(null); + Data.crystalQuota = quota.ToLines(); + MarkChanged(); + } + + string QuotaText(int compensated, int compGold) => + $"Недельный предел синих кристаллов ({quota.Limit} за {quota.WindowMs / CrystalQuota.Day} дн.) исчерпан: вместо {compensated} кристаллов -- {compGold} золота"; + // ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ---------------- readonly CrystalRewards crystals = new CrystalRewards(); @@ -526,7 +577,7 @@ public class GameProgress : MonoBehaviour if (!IsQuestDone(q, daily)) return; int gems = GemsIn(q.rewards); - if (!crystals.CanStart(key, gems, GemsHave, GemsCap)) + if (!crystals.CanStart(key, Mathf.Min(gems, quota.Left(GameClock.UnixNowMs)), GemsHave, GemsCap)) { UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " + "Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится"); @@ -536,14 +587,18 @@ public class GameProgress : MonoBehaviour Data.questsDone++; AddEventPoints(20); - var rest = WithoutGems(q.rewards); - Grant(rest, q.xp, false); - int given = gems > 0 ? GiveQuestGems(key, gems) : 0, left = crystals.Left(key); + // Квота -- один раз на задание (id); сверх неё -- золото. Ждущие места кристаллы квоту второй раз не берут + int compGold = 0, allowed = gems > 0 ? TakeQuota(key, gems, out compGold) : 0; + var rest = new List(WithoutGems(q.rewards)); + if (compGold > 0) rest.Add(new RewardItem(ResourceType.Gold, compGold)); + Grant(rest.ToArray(), q.xp, false, false); + int given = allowed > 0 ? GiveQuestGems(key, allowed) : 0, left = crystals.Left(key); var shown = new List(rest); if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given)); string text = GameData.RewardsText(shown.ToArray()); if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта"; if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях"; + if (gems - allowed > 0) text += ". " + QuotaText(gems - allowed, compGold); UIKit.Toast("Получено: " + text); } @@ -632,7 +687,7 @@ public class GameProgress : MonoBehaviour m.rewards.Clear(); if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме else m.claimed = true; - Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит + Grant(give.ToArray(), 0, toast, false); // всё помещается; квота за них уже учтена при первой выдаче if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме"); return left == 0; } @@ -787,7 +842,7 @@ public class GameProgress : MonoBehaviour // Заказ не встал -- цену возвращаем целиком var refund = new RewardItem[def.cost.Length]; for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count); - Grant(refund, 0, false); + Grant(refund, 0, false, false); // возврат -- не новые кристаллы UIKit.Toast(error); return; } @@ -896,7 +951,7 @@ public class GameProgress : MonoBehaviour Data.battlesWon++; Data.dailyBattles++; AddEventPoints(10); - Grant(res.rewards, res.xp, false); + res.rewards = Grant(res.rewards, res.xp, false); // в окне итога -- что выдано на самом деле (квота, место) } else { diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index d3bf053..d911872 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -71,6 +71,8 @@ public class ProgressData public List claimedDaily = new List(); // Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:|сколько" public List crystalWaiting = new List(); + // Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции" + public List crystalQuota = new List(); public float[] dailyCollected = new float[8]; public int dailyBattles; public int dailyTroops; diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs b/Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs new file mode 100644 index 0000000..6cc6355 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs @@ -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 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] : "" }); + } + } +} diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs.meta b/Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs.meta new file mode 100644 index 0000000..d2a0944 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e1f4c7a2b6d48a3a8c5f0d2e7b39c14 +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 bc67923..699d2ca 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -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"); + } +} diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 71c7eec..786ca07 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -100,10 +100,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-кристалл», «Сбросить квоту». - **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс. - **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов. -**Ещё не сделано:** недельный PvE-предел 6 с заменой излишка золотом; редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. +**Ещё не сделано:** редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. **Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты. @@ -128,6 +129,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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. | | 06.11.2026 | Claude (Саша) | Регламент валют 05.11, разд. 2 и 5.1: синие кристаллы только за верхнюю ступень недельного события (1) и каждый 10-й этап боя (1); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». |