From a2d85f56c398121f4bce47e0ad1c370a74fa3968 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:13:43 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=D0=A5=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=89=D0=B5=20=D1=81=D0=B8=D0=BD=D0=B8=D1=85=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B0=D0=BB=D0=BB=D0=BE=D0=B2:=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=2050=20->=2075/100/150/200?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=A0=D0=B0=D1=82=D1=83=D1=88=D0=B5=203/5/7/?= =?UTF-8?q?9=20(=D1=80=D0=B5=D0=B3=D0=BB=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D1=8E=D1=82,=20=D1=80=D0=B0=D0=B7=D0=B4.=208?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CrystalStorage (без Unity) + 5 тестов из раздела 12 - Ступени и цены -- EconomyConfig.crystalExpansions (ТЕСТ), купленная ступень -- в секции economy - Окно Ратуши: «Хранилище синих кристаллов», «Расширить до N»; цена списывается один раз Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/App/BuildingWindow.cs | 41 +++++++++ Assets/Scripts/App/EconomyConfig.cs | 67 ++++++++++++++- .../Modules/Economy/ResourceManager.cs | 9 +- .../Modules/Economy/Rules/CrystalStorage.cs | 63 ++++++++++++++ .../Economy/Rules/CrystalStorage.cs.meta | 11 +++ .../Scripts/Modules/Tests/ProductionTests.cs | 85 +++++++++++++++++++ Docs/Modules/Economy.md | 6 +- 7 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs.meta diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 77f3930..34bd2fd 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -282,6 +282,8 @@ public static class BuildingWindow Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim); } + CrystalStorageBlock(card, level); + if (level >= townHall.maxLevel) { UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); @@ -323,6 +325,45 @@ public static class BuildingWindow LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade()); } + // Хранилище синих кристаллов (регламент валют 05.11, разд. 8; ответ 06.11, п. 1): вместимость и платное + // расширение. Ратуша только разрешает ступень -- игрок покупает её здесь за золото и обычные ресурсы + static void CrystalStorageBlock(RectTransform card, int townHallLevel) + { + var storage = EconomyConfig.CrystalStorageNow(); + Header(card, "Хранилище синих кристаллов"); + Live(card, () => + { + float have = GameServices.TryGet(out IEconomy e) ? e.Get(ResourceType.Gems) : 0f; + return $"Кристаллов {have:0} / {EconomyConfig.CrystalStorageNow().Capacity}"; + }, 24, UIKit.Theme.Text); + + var next = storage.Next; + string why = storage.WhyNot(townHallLevel); + if (next == null) + { + UIKit.Text(card, "Хранилище расширено до предела", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + return; + } + var cost = next.Cost ?? new ResourceAmount[0]; + if (why != null) + { + UIKit.Text(card, why + ". Цена: " + CostText(cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + return; + } + LiveCost(card, cost); + bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost); + var b = UIKit.Button(card, $"Расширить до {next.Capacity}", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => + { + if (EconomyConfig.TryExpandCrystalStorage(townHallLevel, out string error)) + { + UIKit.Toast($"Хранилище кристаллов: {EconomyConfig.CrystalStorageNow().Capacity}"); + Rebuild(); + } + else UIKit.Toast(error); + }, -1, 80, 26); + LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost)); + } + // Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4" static string MoreBuildingsAt(int level, int next) { diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index 5f0ce3b..d3f0894 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System; using UnityEngine; @@ -70,6 +71,20 @@ public class EconomyConfig : ScriptableObject [Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f; } + [Serializable] + public class CrystalExpansion + { + public int townHallLevel = 3; + public int capacity = 75; + public float gold, wood, stone, iron; + + public CrystalExpansion() { } + public CrystalExpansion(int townHall, int cap, float gold, float wood, float stone, float iron) + { + townHallLevel = townHall; capacity = cap; this.gold = gold; this.wood = wood; this.stone = stone; this.iron = iron; + } + } + [Serializable] public class StorageSettings // этап 1: лимиты хранения { @@ -81,8 +96,17 @@ public class EconomyConfig : ScriptableObject public float perWarehouseLevel = 1000f; [Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")] public float perGranaryLevel = 1500f; - [Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")] + [Tooltip("Стартовая вместимость синих кристаллов (регламент валют 05.11, разд. 8). ТЕСТ")] public float crystalCap = 50f; + [Tooltip("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " + + "Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")] + public CrystalExpansion[] crystalExpansions = + { + new CrystalExpansion(3, 75, 2000, 1500, 1500, 0), + new CrystalExpansion(5, 100, 4000, 3000, 3000, 1000), + new CrystalExpansion(7, 150, 7000, 5000, 5000, 2000), + new CrystalExpansion(9, 200, 10000, 8000, 8000, 4000), + }; [Tooltip("Цена Склада: дерево / камень. Не утверждено")] public float warehouseWood = 200f; @@ -253,7 +277,7 @@ public class EconomyConfig : ScriptableObject case ResourceType.Iron: case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels; case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels; - case ResourceType.Gems: return s.crystalCap; + case ResourceType.Gems: return CrystalStorageNow().Capacity; // стартовая + купленные расширения default: return float.PositiveInfinity; } } @@ -326,4 +350,43 @@ public class EconomyConfig : ScriptableObject default: return 0f; } } + + // ---------------- Хранилище синих кристаллов (регламент валют 05.11, разд. 8; правила -- CrystalStorage) ---------------- + + // Правила с купленной ступенью (её хранит Экономика: ResourceManager.CrystalStorageLevel, секция "economy") + public static CrystalStorage CrystalStorageNow() + { + var c = Load(); + var tiers = new List(); + if (c.storage.crystalExpansions != null) + foreach (var e in c.storage.crystalExpansions) + { + if (e == null) continue; + var cost = new List(); + if (e.gold > 0) cost.Add(new ResourceAmount(ResourceType.Gold, e.gold)); + if (e.wood > 0) cost.Add(new ResourceAmount(ResourceType.Wood, e.wood)); + if (e.stone > 0) cost.Add(new ResourceAmount(ResourceType.Stone, e.stone)); + if (e.iron > 0) cost.Add(new ResourceAmount(ResourceType.Iron, e.iron)); + tiers.Add(new CrystalStorage.Tier { TownHallLevel = e.townHallLevel, Capacity = e.capacity, Cost = cost.ToArray() }); + } + var storage = new CrystalStorage(Mathf.RoundToInt(c.storage.crystalCap), tiers); + if (ResourceManager.Instance != null) storage.SetLevel(ResourceManager.Instance.CrystalStorageLevel); + return storage; + } + + // Купить следующую ступень: цена списывается один раз (всё или ничего), ступень и ресурсы -- в одной секции + // сохранения "economy", сохраняется сразу. error -- почему нельзя + public static bool TryExpandCrystalStorage(int townHallLevel, out string error) + { + var rm = ResourceManager.Instance; + var storage = CrystalStorageNow(); + error = storage.WhyNot(townHallLevel); + if (error != null) return false; + if (rm == null || !GameServices.TryGet(out IEconomy economy)) { error = "Склад не найден"; return false; } + if (!storage.TryExpand(townHallLevel, cost => economy.TrySpend(cost))) { error = "Не хватает ресурсов"; return false; } + rm.CrystalStorageLevel = storage.Level; + rm.SetCapacity(ResourceType.Gems, storage.Capacity); + if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); + return true; + } } diff --git a/Assets/Scripts/Modules/Economy/ResourceManager.cs b/Assets/Scripts/Modules/Economy/ResourceManager.cs index 0e7382a..43d5efc 100644 --- a/Assets/Scripts/Modules/Economy/ResourceManager.cs +++ b/Assets/Scripts/Modules/Economy/ResourceManager.cs @@ -30,6 +30,10 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant // запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют. private Dictionary capacities = new Dictionary(); + // Сколько платных расширений хранилища синих кристаллов куплено (регламент валют 05.11, разд. 8). + // Вместимость по ней считает слой приложения (EconomyConfig.CrystalStorageNow); здесь -- только хранится + public int CrystalStorageLevel { get; set; } + // Лимит изменился (для интерфейса: "запас / лимит") public event Action OnCapacityChanged; @@ -189,13 +193,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant { public List resources = new List(); public List capacities = new List(); // лимиты склада (этап 1) + public int crystalStorageLevel; // купленные расширения хранилища кристаллов (06.11); нет в старом файле -- 0 } public string SaveKey => "economy"; public string Capture() { - var data = new EconomySaveData(); + var data = new EconomySaveData { crystalStorageLevel = CrystalStorageLevel }; foreach (var kvp in resources) data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value }); foreach (var kvp in capacities) @@ -207,6 +212,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant { var data = JsonUtility.FromJson(json); if (data?.resources == null) return; + CrystalStorageLevel = Mathf.Max(0, data.crystalStorageLevel); if (data.capacities != null) foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount); foreach (var entry in data.resources) SetResource(entry.type, entry.amount); @@ -215,6 +221,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant // Новая игра -- стартовые значения из Inspector public void ResetState() { + CrystalStorageLevel = 0; SetStartValues(notify: true); } } diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs b/Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs new file mode 100644 index 0000000..c2603f6 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +// ХРАНИЛИЩЕ СИНИХ КРИСТАЛЛОВ: вместимость и платное расширение +// (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 8; «Ответ Володе» от 06.11.2026, п. 1). +// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы». +// +// - Старт -- BaseCapacity (ТЕСТ 50). Ступени (ТЕСТ): Ратуша 3 -- 75, 5 -- 100, 7 -- 150, 9 -- 200. +// - Ступень НЕ открывается сама с уровнем Ратуши: Ратуша только разрешает её, игрок покупает за золото и +// обычные ресурсы. Ступени -- по порядку, одна покупка -- одна ступень. +// - Цена списывается один раз: ступень растёт, только если списание прошло (всё или ничего). Повторное +// нажатие без следующей ступени или без нужной Ратуши ничего не списывает. +// - Сохраняется номер купленной ступени (Level), вместимость по нему считается заново -- числа можно менять. +// Без Unity: те же правила будут на сервере и в тестах. Числа и цены даёт слой приложения (EconomyConfig). +public sealed class CrystalStorage +{ + public sealed class Tier + { + public int TownHallLevel; // с какого уровня Ратуши можно купить + public int Capacity; // вместимость после покупки + public ResourceAmount[] Cost; // цена: золото и обычные ресурсы + } + + public readonly int BaseCapacity; + readonly List tiers; + + public CrystalStorage(int baseCapacity, IEnumerable tiers) + { + BaseCapacity = Math.Max(0, baseCapacity); + this.tiers = new List(); + if (tiers != null) foreach (var t in tiers) if (t != null) this.tiers.Add(t); + } + + // Сколько ступеней куплено (0 -- только стартовая вместимость) + public int Level { get; private set; } + + public int TierCount => tiers.Count; + public int Capacity => Level <= 0 ? BaseCapacity : Math.Max(BaseCapacity, tiers[Level - 1].Capacity); + + // Следующая ступень; null -- все куплены + public Tier Next => Level < tiers.Count ? tiers[Level] : null; + + // Восстановить из сохранения (номер ступени; лишнее обрезается, если ступеней стало меньше) + public void SetLevel(int level) => Level = Math.Max(0, Math.Min(level, tiers.Count)); + + // Почему сейчас нельзя расширить: null -- можно (если хватит ресурсов -- это проверяет списание) + public string WhyNot(int townHallLevel) + { + var next = Next; + if (next == null) return "Хранилище расширено до предела"; + if (townHallLevel < next.TownHallLevel) return $"Расширение до {next.Capacity} откроется на Ратуше {next.TownHallLevel} ур."; + return null; + } + + // Купить следующую ступень. trySpend -- списание цены «всё или ничего» (Экономика). true -- ступень куплена + public bool TryExpand(int townHallLevel, Func, bool> trySpend) + { + if (WhyNot(townHallLevel) != null || trySpend == null) return false; + if (!trySpend(Next.Cost ?? new ResourceAmount[0])) return false; + Level++; + return true; + } +} diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs.meta b/Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs.meta new file mode 100644 index 0000000..b33011b --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b8d2f6a1c9e47d3b5a0e7f3c2d19a85 +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 f433732..bc67923 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -429,3 +429,88 @@ public class CrystalRewardsTests Assert.AreEqual(10, CrystalRewards.Fits(10, 0, float.PositiveInfinity), "без предела -- целиком"); } } + +// Хранилище синих кристаллов: платное расширение по уровню Ратуши (регламент валют 05.11, разд. 8 и 12) +public class CrystalStorageTests +{ + static CrystalStorage Storage() => new CrystalStorage(50, new[] + { + new CrystalStorage.Tier { TownHallLevel = 3, Capacity = 75, Cost = new[] { new ResourceAmount(ResourceType.Gold, 2000) } }, + new CrystalStorage.Tier { TownHallLevel = 5, Capacity = 100, Cost = new[] { new ResourceAmount(ResourceType.Gold, 4000) } }, + new CrystalStorage.Tier { TownHallLevel = 7, Capacity = 150, Cost = new[] { new ResourceAmount(ResourceType.Gold, 7000) } }, + new CrystalStorage.Tier { TownHallLevel = 9, Capacity = 200, Cost = new[] { new ResourceAmount(ResourceType.Gold, 10000) } }, + }); + + [Test] + public void Storage_NotAutomatic_TownHallOnlyAllows() + { + var s = Storage(); + Assert.AreEqual(50, s.Capacity, "старт 50"); + int spent = 0; + Assert.IsFalse(s.TryExpand(2, c => { spent++; return true; }), "Ратуша 2 -- расширения нет"); + Assert.AreEqual(0, spent, "ничего не списано"); + Assert.AreEqual(50, s.Capacity, "Ратуша 3 сама вместимость не меняет -- только разрешает"); + Assert.IsNull(s.WhyNot(3)); + } + + [Test] + public void Storage_50to75_PaidOnce_AndKeptAfterSaveLoad() + { + var s = Storage(); + float gold = 2500; + System.Func, bool> pay = cost => + { + float need = 0; + foreach (var c in cost) if (c.Type == ResourceType.Gold) need += c.Amount; + if (gold < need) return false; + gold -= need; + return true; + }; + Assert.IsTrue(s.TryExpand(3, pay)); + Assert.AreEqual(75, s.Capacity, "50 -> 75 после оплаты"); + Assert.AreEqual(500, gold, 0.01, "списано один раз"); + Assert.IsFalse(s.TryExpand(3, pay), "повторное нажатие на Ратуше 3 -- следующая ступень ещё закрыта"); + Assert.AreEqual(500, gold, 0.01, "второй раз не списано"); + + var loaded = Storage(); + loaded.SetLevel(s.Level); + Assert.AreEqual(75, loaded.Capacity, "после сохранения и загрузки -- 75"); + } + + [Test] + public void Storage_NotEnoughResources_NothingChanges() + { + var s = Storage(); + Assert.IsFalse(s.TryExpand(9, c => false), "не хватило -- списание не прошло"); + Assert.AreEqual(0, s.Level); + Assert.AreEqual(50, s.Capacity); + } + + [Test] + public void Storage_TiersInOrder_UpTo200() + { + var s = Storage(); + for (int i = 0; i < 4; i++) Assert.IsTrue(s.TryExpand(10, c => true)); + Assert.AreEqual(200, s.Capacity); + Assert.IsNull(s.Next); + Assert.IsFalse(s.TryExpand(10, c => true), "дальше 200 -- нет"); + s.SetLevel(99); + Assert.AreEqual(4, s.Level, "лишние ступени из сохранения обрезаются"); + } + + [Test] + public void Storage_Waiting_ClaimedOnceAfterExpansion() + { + // 49/50 + допустимая награда 2 -> 1 сразу и 1 ждёт; после расширения ждущий забирается один раз + var s = Storage(); + var r = new CrystalRewards(); + float have = 49; + have += r.Claim("q", 2, have, s.Capacity); + Assert.AreEqual(50, have, 0.01); + Assert.AreEqual(1, r.Left("q")); + s.TryExpand(3, c => true); + have += r.Claim("q", 2, have, s.Capacity); + Assert.AreEqual(51, have, 0.01, "после расширения выдан ждущий 1"); + Assert.AreEqual(0, r.Left("q"), "больше ничего не ждёт -- выдан ровно один раз"); + } +} diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index ea66a2f..0c150b3 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -99,11 +99,12 @@ - каждый 10-й этап боя — 1 (`StageGemsEvery`, `StageGemsAmount`). Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 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), вместимость по ней считается заново. - **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов. -**Ещё не сделано (после вливания `feature/3d-hud` в `develop`):** вместимость 50 → 75 / 100 / 150 / 200 с платным расширением на Ратуше 3 / 5 / 7 / 9 (сейчас константа `crystalCap` 50); недельный PvE-предел 6 с заменой излишка золотом; редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. +**Ещё не сделано:** недельный PvE-предел 6 с заменой излишка золотом; редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. -**Открыто:** цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты, ускорения за кристаллы (обучение — 1 кристалл за 30 с, стройка) — в регламенте среди трат не названы. +**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты, ускорения за кристаллы (обучение — 1 кристалл за 30 с, стройка) — в регламенте среди трат не названы. ## Таблица баланса `EconomyConfig` @@ -126,6 +127,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». | | 05.11.2026 | Claude (Саша) | Регламент синих кристаллов, пп. 1 и 3: кристаллы не начисляются сверх 50 и не теряются — награды заданий ждут (не больше 2), остальные — письмом «Кристаллы ждут места», из письма забирается сколько помещается (`CrystalRewards`, `GameProgress.Grant/ClaimQuest/ClaimMail`, 6 тестов). Задания — 1–2 кристалла вместо 20–250, ежедневные — без кристаллов. | | 04.11.2026 | Claude | Решения 04.11: кристаллы грабятся только нападающим с технологией «Грабёж» (`IResourceProtection.Lootable(type, attackerHasPlunder)`; с ней — не больше `plunderGemsShare` запаса, ТЕСТ 10%, точные ограничения — позже). Защита остальных ресурсов без изменений. Из `EconomyConfig` убран возврат при сносе. Офлайн: добыча здания, достроенного в офлайне, — с момента завершения; уже посчитанное время второй раз не начисляется (тесты в `ProductionTests`). | -- 2.54.0 From e1228e0b3e994486f6cc37918171c753e1c4fe4c Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:17:38 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=D0=9D=D0=B0=D0=B3=D1=80=D0=B0=D0=B4=D1=8B?= =?UTF-8?q?=20=D0=B7=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4:=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=97=D0=B0=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B0=D1=80=D0=BE=D0=BA=C2=BB?= =?UTF-8?q?=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83=20--=20=D0=B2=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B7=D0=BE=D0=BD=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=B8=D0=B6=D0=BD=D1=8E=D1=8E=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20HUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Meta/Panels/RewardsPanel.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/Meta/Panels/RewardsPanel.cs b/Assets/Scripts/Meta/Panels/RewardsPanel.cs index 56cf83d..082303b 100644 --- a/Assets/Scripts/Meta/Panels/RewardsPanel.cs +++ b/Assets/Scripts/Meta/Panels/RewardsPanel.cs @@ -16,13 +16,21 @@ public class RewardsPanel : BasePanel bool canClaim = GP.CanClaimLogin; int total = GameData.LoginRewards.Length; + + // Кнопка и таймер -- сверху: в горизонтальном экране окно низкое, и под календарём кнопка уходила + // за нижнюю панель HUD -- её не было видно (06.11) + var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray, + () => GP.ClaimLogin(), -1, 90, 32); + b.interactable = canClaim; + timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40); + int claimedUpTo = d.loginRewardIndex; // сколько дней цикла уже забрано if (!canClaim && claimedUpTo == 0 && d.lastLoginRewardDay >= 0) claimedUpTo = total; // только что забрали 7-й RectTransform row = null; for (int i = 0; i < total; i++) { - if (i % 4 == 0) row = UIKit.Row(list, 230, 12); + if (i % 4 == 0) row = UIKit.Row(list, 170, 12); bool claimed = i < claimedUpTo; bool current = canClaim && i == d.loginRewardIndex; @@ -39,12 +47,6 @@ public class RewardsPanel : BasePanel claimed ? UIKit.Theme.Green : UIKit.Theme.Gold, TextAlignmentOptions.Center); } - UIKit.Spacer(list, 10); - var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray, - () => GP.ClaimLogin(), -1, 110, 36); - b.interactable = canClaim; - - timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50); Tick(); } -- 2.54.0 From 1ce4082b4cebd80a38d8c63653e1909420981b82 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:18:28 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=D0=A3=D1=81=D0=BA=D0=BE=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B7=D0=B0=20=D0=B7=D0=BE=D0=BB=D0=BE=D1=82?= =?UTF-8?q?=D0=BE:=20=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0,=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D0=BA=20--=201=20=D0=B7=D0=BE=D0=BB=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=2010=20=D1=81=20(=D0=A2=D0=95=D0=A1=D0=A2?= =?UTF-8?q?,=20secondsPerGold),=20=D0=B1=D1=8B=D0=BB=D0=BE=201=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D1=81=D1=82=D0=B0=D0=BB=D0=BB=20=D0=B7=D0=B0=2030?= =?UTF-8?q?=20=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/App/BuildingWindow.cs | 14 +++++++------- Assets/Scripts/App/EconomyConfig.cs | 12 +++++++++++- Assets/Scripts/Meta/Core/GameProgress.cs | 4 ++-- Assets/Scripts/Meta/Panels/TroopsPanel.cs | 4 ++-- Docs/Modules/Economy.md | 4 +++- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 34bd2fd..d3b327b 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -641,21 +641,21 @@ public static class BuildingWindow live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); }); Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text); - int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem); - Func gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0; + // Ускорение -- за золото (06.11, ТЕСТ: EconomyConfig.construction.secondsPerGold) + Func gold = () => t != null ? EconomyConfig.SpeedUpGold(t.SecondsLeft) : 0; var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () => { if (t == null) return; - int price = gems(); - if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price)) + int price = gold(); + if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gold, price)) { - UIKit.Toast($"Нужно {price} кристаллов"); + UIKit.Toast($"Нужно {price} золота"); return; } t.Complete(); // окно перестроит OnBuildCompleted }, -1, 80, 26); - live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); }); - UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); + live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true); }); + UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true); } // ---------------- Перенос (решения 04.11: сноса нет) ---------------- diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index d3f0894..f42f317 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -161,8 +161,11 @@ public class EconomyConfig : ScriptableObject public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 }; [Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")] public float townHallTimeMultiplier = 2f; - [Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")] + [Tooltip("Устарело (06.11): ускорение теперь за золото -- secondsPerGold")] public int secondsPerGem = 30; + [Tooltip("Ускорение стройки, улучшения и обучения войск -- за золото (06.11; регламент валют: золото -- основной ресурс " + + "построек и улучшений, синие кристаллы на ускорения не тратятся): секунд за 1 золото. ТЕСТ")] + public int secondsPerGold = 10; // Сноса зданий нет (решение 04.11): только бесплатный перенос } @@ -351,6 +354,13 @@ public class EconomyConfig : ScriptableObject } } + // Цена ускорения в золоте: сколько осталось секунд -> золото (не меньше 1). Стройка, улучшение и обучение войск + public static int SpeedUpGold(double secondsLeft) + { + int perGold = Mathf.Max(1, Load().construction.secondsPerGold); + return Mathf.Max(1, Mathf.CeilToInt((float)(secondsLeft / perGold))); + } + // ---------------- Хранилище синих кристаллов (регламент валют 05.11, разд. 8; правила -- CrystalStorage) ---------------- // Правила с купленной ступенью (её хранит Экономика: ResourceManager.CrystalStorageLevel, секция "economy") diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 15e39f7..0f90f31 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -811,14 +811,14 @@ public class GameProgress : MonoBehaviour var queue = Training; if (queue.Count == 0) return 0; double secs = TrainingSecondsLeft(queue[queue.Count - 1]); - return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек + return EconomyConfig.SpeedUpGold(secs); // за золото (06.11, ТЕСТ; было 1 кристалл за 30 с) } } public void SpeedUpTraining() { if (Training.Count == 0) return; - if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return; + if (!Pay(new[] { new RewardItem(ResourceType.Gold, SpeedUpCost) })) return; Army.FinishAllTraining(GameClock.UnixNowMs); } diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 2362030..6ff7349 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -4,7 +4,7 @@ using UnityEngine.UI; using TMPro; // ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы, -// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена, +// ускорение за золото. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена, // против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм. public class TroopsPanel : BasePanel { @@ -293,6 +293,6 @@ public class TroopsPanel : BasePanel queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o)); } if (speedUp != null && training.Count > 0) - UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true); + UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " золота", true); } } diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 0c150b3..71c7eec 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -100,11 +100,12 @@ Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 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), вместимость по ней считается заново. +- **Ускорения — за золото** (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 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. -**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты, ускорения за кристаллы (обучение — 1 кристалл за 30 с, стройка) — в регламенте среди трат не названы. +**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты. ## Таблица баланса `EconomyConfig` @@ -127,6 +128,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». | | 05.11.2026 | Claude (Саша) | Регламент синих кристаллов, пп. 1 и 3: кристаллы не начисляются сверх 50 и не теряются — награды заданий ждут (не больше 2), остальные — письмом «Кристаллы ждут места», из письма забирается сколько помещается (`CrystalRewards`, `GameProgress.Grant/ClaimQuest/ClaimMail`, 6 тестов). Задания — 1–2 кристалла вместо 20–250, ежедневные — без кристаллов. | -- 2.54.0 From e05cf8717381d5047e90b7b583ecc427c51870ce Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:20:57 +0100 Subject: [PATCH 4/7] =?UTF-8?q?3D:=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8=20=D0=B7=D0=BE=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D0=B0=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BA=D1=80=D0=B0=D0=B9=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=BE=D0=BD=D1=8B=20(=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D1=8F=20=D0=B7=D0=BE=D0=BD=D0=B0=20+=202=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B5=D1=82=D0=BA=D0=B8),=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B2=D1=81=D1=8E=20--=20=D0=BD=D0=B5=20=D1=83=D0=BB=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D1=82=20=D0=B4=D0=B0=D0=BB=D0=B5=D0=BA=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/City3D/City3DCamera.cs | 4 +++- Assets/Scripts/City3D/City3DController.cs | 10 +++++++--- Docs/Modules/Settlement.md | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/City3D/City3DCamera.cs b/Assets/Scripts/City3D/City3DCamera.cs index fa56c53..5fc80c4 100644 --- a/Assets/Scripts/City3D/City3DCamera.cs +++ b/Assets/Scripts/City3D/City3DCamera.cs @@ -87,11 +87,13 @@ public class City3DCamera : MonoBehaviour } } - public void FocusOn(Rect area) + // onlyZoomOut -- не приближать: если камера уже дальше, расстояние остаётся (открытие зоны, 06.11) + public void FocusOn(Rect area, bool onlyZoomOut = false) { focusTarget = new Vector3(area.center.x, 0, area.center.y); // отойти так, чтобы область поместилась между HUD сверху и снизу focusDistance = Mathf.Clamp(FitDistance(area), minDistance, MaxDistanceNow); + if (onlyZoomOut) focusDistance = Mathf.Max(focusDistance, Mathf.Min(distance, MaxDistanceNow)); focusing = true; zoomTo = -1f; } diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index c95ad13..75ec8b1 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -982,14 +982,18 @@ public class City3DController : MonoBehaviour, City3DSave.ILive if (ghost != null) UpdateGhost(); if (townHallLevel > before) { - // Открылась зона -- камера плавно отходит и показывает её; здания остаются на местах - int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2; - cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2)); + // Открылась зона -- камера плавно отходит и показывает её КРАЙ: старая зона и ZoneEdgeCells клеток новой за её + // границей (06.11, Саша: вся зона целиком -- камера улетала слишком далеко). Ближе, чем была, не подъезжает + int c = City3DLayout.Size / 2; + int show = Mathf.Min(City3DLayout.ZoneHalfSize(townHallLevel), City3DLayout.ZoneHalfSize(before) + ZoneEdgeCells); + cameraRig.FocusOn(new Rect(c - show, c - show, show * 2, show * 2), onlyZoomOut: true); Say($"Ратуша {townHallLevel} ур.: открыта новая зона"); if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места } } + const int ZoneEdgeCells = 2; // сколько клеток новой зоны показать при её открытии + // Камера: центр кадра -- только над открытой зоной, при самом дальнем отдалении видна вся зона (06.11) void UpdateCameraArea() { diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index a0d0d95..0981039 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -119,6 +119,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. | | 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. | | 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. | | 05.11.2026 | Claude (Саша) | **Решение команды: только 3D-поселение, камера перспективная.** (1) В 3D перенесены Золотой рудник (золото, Ратуша 2, 50 камня), Кристалл (Ратуша 6, цена из `Data_Kristall`) и Терем (`HeroCastle`, окно героя; фигурка героя гуляет только по 2D-сетке, в 3D — нет). (2) Перенос 2D-города: если в сохранении нет `settlement3d`, а есть 2D-город, при первом входе в 3D Ратуша получает уровень и улучшение из 2D, остальные здания встают на ближайшие свободные места с теми же уровнями и стройками (`City3DController.MoveFrom2D`, итог — в Console «[3D] Перенос 2D-города»). Секция `settlement` в файле не меняется. (3) `SceneNavigator.CitySceneName` = City3D; MVP1 выключена в Build Settings (сцена и код 2D остаются в репозитории до конца проверки). (4) `City3DCamera.perspective` (по умолчанию да, поле зрения 40°) — угол, высота и поле зрения ТЕСТ. Меню стройки — в два столбца. | -- 2.54.0 From 277afdf84eee13b5da8d38f7a6f31cbbff6145e2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:24:16 +0100 Subject: [PATCH 5/7] =?UTF-8?q?=D0=9D=D0=B5=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20PvE-=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D0=B8=D1=85=20=D0=BA=D1=80=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D0=BE=D0=B2:=206=20=D0=B7=D0=B0=207=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=81=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D0=BA,=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=20--=20=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D1=82=D0=BE=20(=D1=80=D0=B5=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82?= =?UTF-8?q?=2005.11,=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=2006.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CrystalQuota (без Unity) + 6 тестов из раздела 12 - Задания, события, бои: в пределах квоты -- кристаллы, сверх -- 1000 золота за кристалл (ТЕСТ) - Ждущие места кристаллы квоту второй раз не тратят; письмо «ждут места» -- вне квоты - Окно Ратуши: «Новых кристаллов за 7 дней: N / 6»; DEV: «+1 PvE-кристалл», «Сбросить квоту» Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/App/BuildingWindow.cs | 9 ++ Assets/Scripts/App/DebugPanel.cs | 5 + Assets/Scripts/App/EconomyConfig.cs | 7 ++ Assets/Scripts/Meta/Core/GameProgress.cs | 77 ++++++++++++--- Assets/Scripts/Meta/Core/ProgressData.cs | 2 + .../Modules/Economy/Rules/CrystalQuota.cs | 94 +++++++++++++++++++ .../Economy/Rules/CrystalQuota.cs.meta | 11 +++ .../Scripts/Modules/Tests/ProductionTests.cs | 70 ++++++++++++++ Docs/Modules/Economy.md | 4 +- 9 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalQuota.cs.meta 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); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». | -- 2.54.0 From b51ad419256f625ca5e5279cfbf7d7eccee26bd3 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:27:27 +0100 Subject: [PATCH 6/7] =?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. | -- 2.54.0 From 157d3ba5109a6b3b127f146c4f184af5553503b6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:31:47 +0100 Subject: [PATCH 7/7] =?UTF-8?q?=D0=9A=D1=80=D0=B8=D1=81=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=88=D0=B0=D1=85=D1=82=D0=B0:=203?= =?UTF-8?q?=20=D1=81=D0=B8=D0=BD=D0=B8=D1=85=20=D0=BA=D1=80=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BB=D0=B0=20=D0=B7=D0=B0=207=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=81=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D1=88=D0=B0=D1=85=D1=82=D1=83,?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D0=B4=D0=B2=D1=83=D1=85,=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=89=D0=B5=20(=D0=BE=D1=82=D0=B2=D0=B5=D1=82=2006.11,=20?= =?UTF-8?q?=D0=BF.=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CrystalMine (без Unity) + 5 тестов: ровно 3 за 7 суток, ×12 не действует, 49/50 -> 1, пауза, перезагрузка и часы назад без двойного начисления, офлайн-стройка - Работает с конца стройки, офлайн досчитывается; вне недельного PvE-предела - Вторая шахта -- Ратуша 9 (ТЕСТ); окно здания: следующий кристалл / пауза - DEV «Ресурсы 100 000» -- обычные ресурсы мимо лимита склада Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/App/BuildingWindow.cs | 15 +++- Assets/Scripts/App/DebugPanel.cs | 12 ++- Assets/Scripts/App/EconomyConfig.cs | 6 +- Assets/Scripts/City3D/City3DController.cs | 1 + Assets/Scripts/City3D/City3DGameplay.cs | 29 +++++++ Assets/Scripts/City3D/City3DSave.cs | 4 + Assets/Scripts/Meta/Core/GameProgress.cs | 41 +++++++++ Assets/Scripts/Meta/Core/ProgressData.cs | 2 + .../Modules/Economy/Rules/CrystalMine.cs | 83 +++++++++++++++++++ .../Modules/Economy/Rules/CrystalMine.cs.meta | 11 +++ .../Scripts/Modules/Tests/ProductionTests.cs | 65 +++++++++++++++ Docs/Modules/Economy.md | 8 +- 12 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs create mode 100644 Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs.meta diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 0175b74..6ea0648 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -204,10 +204,23 @@ public static class BuildingWindow // ---------------- Кристалл ---------------- + // Кристальная шахта (ответ 06.11, п. 3): 3 синих кристалла за 7 реальных суток, при полном хранилище -- пауза static void CrystalSection(RectTransform card) { - UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + var gp = GameProgress.Instance; + if (gp != null) + UIKit.Text(card, $"Кристальная шахта: {gp.CrystalMinePerPeriod} синих кристалла за {gp.CrystalMinePeriodDays} реальных суток, " + + "и пока игра закрыта. Не входит в недельный предел. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems); + if (gp == null) return; + Live(card, () => + { + int working = gp.CrystalMinesWorking; + if (working == 0) return "Шахта ещё не работает"; + if (CrystalRewards.Free(GameProgress.GemsHave, GameProgress.GemsCap) <= 0) + return "Пауза: хранилище кристаллов заполнено. Освободите место или расширьте его в окне Ратуши"; + return $"Работает шахт: {working}. Следующий кристалл через {GameData.FormatTime(gp.CrystalMineSecondsToNext)}"; + }, 24, UIKit.Theme.Text); } // ---------------- Склад и Амбар ---------------- diff --git a/Assets/Scripts/App/DebugPanel.cs b/Assets/Scripts/App/DebugPanel.cs index 312d52e..2403206 100644 --- a/Assets/Scripts/App/DebugPanel.cs +++ b/Assets/Scripts/App/DebugPanel.cs @@ -80,8 +80,16 @@ 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(); }); + // Только для проверки торговца (30 000 / 60 000 золота) и Кристальной шахты (12 000 золота, 3200 камня...): + // обычные ресурсы ставятся напрямую, мимо лимита склада + Btn(rowQ, "Ресурсы 100 000", UIKit.Theme.Green, () => + { + var rm = ResourceManager.Instance; + if (rm != null) + foreach (var t in new[] { ResourceType.Gold, ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Food }) + rm.SetResource(t, 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 a5bc438..cbeb2b4 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -105,6 +105,10 @@ public class EconomyConfig : ScriptableObject public int crystalPveWindowDays = 7; [Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")] public float crystalCompensationGold = 1000f; + [Tooltip("Кристальная шахта: синих кристаллов за период на одну работающую шахту (ответ 06.11, п. 3). ТЕСТ")] + public int crystalMinePerPeriod = 3; + [Tooltip("Кристальная шахта: период, РЕАЛЬНЫХ суток (не игровые часы ×12). ТЕСТ")] + public int crystalMinePeriodDays = 7; [Tooltip("Редкий PvE-торговец: цена синего кристалла в золоте по порядку покупок за 7 реальных суток; покупок не больше, " + "чем цен (регламент валют 05.11, разд. 3). Входит в недельный PvE-предел. ТЕСТ")] public int[] crystalTraderPrices = { 30000, 60000 }; @@ -256,7 +260,7 @@ public class EconomyConfig : ScriptableObject new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3), new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), - new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1), + new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 2, 2), // Кристальная шахта: вторая -- на Ратуше 9 (ответ 06.11, п. 3; уровень ТЕСТ) }; [Header("Защита ресурсов от грабежа (ТЕСТ)")] diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index 75ec8b1..a151365 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -115,6 +115,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive } bool stateApplied; // сохранённый город уже поставлен (в первом кадре, после загрузки сохранения в Start) + public bool StateApplied => stateApplied; void OnEnable() { diff --git a/Assets/Scripts/City3D/City3DGameplay.cs b/Assets/Scripts/City3D/City3DGameplay.cs index e28bd89..6c7b6f5 100644 --- a/Assets/Scripts/City3D/City3DGameplay.cs +++ b/Assets/Scripts/City3D/City3DGameplay.cs @@ -116,6 +116,35 @@ public static class City3DGameplay return string.Join(", ", parts.ToArray()); } + // Кристальные шахты: с какого момента каждая добывает (мс; long.MinValue -- давно достроена; конец стройки -- если + // ещё строится или достроилась офлайн). Город открыт -- по сцене, иначе -- по сохранению settlement3d. + // null -- город ещё не загружен: считать рано (иначе офлайн-время шахты пропало бы) + public static List CrystalMinesActiveFrom() + { + var list = new List(); + var city = City3DController.Instance; + if (city != null) + { + if (!city.StateApplied) return null; + foreach (var id in Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (id.buildingName != "Кристалл") continue; + var t = BuildTimer.Of(id.gameObject); + list.Add(t != null && BuildTimer.IsConstructing(id.transform) ? t.endMs : long.MinValue); + } + return list; + } + if (!City3DSave.Loaded) return null; + if (City3DSave.Cached != null) + foreach (var e in City3DSave.Cached.buildings) + { + if (e == null || e.type != "Кристалл") continue; + bool building = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1 && e.timerEndMs > 0; + list.Add(building ? e.timerEndMs : long.MinValue); + } + return list; + } + public static int CountBuilt(string gameName) { int n = 0; diff --git a/Assets/Scripts/City3D/City3DSave.cs b/Assets/Scripts/City3D/City3DSave.cs index c61d015..e9082d7 100644 --- a/Assets/Scripts/City3D/City3DSave.cs +++ b/Assets/Scripts/City3D/City3DSave.cs @@ -39,6 +39,8 @@ public sealed class City3DSave : ISaveParticipant public static readonly City3DSave Instance = new City3DSave(); public static Data Cached { get; private set; } + // Сохранение уже прочитано (или новая игра): по Cached можно считать здания вне 3D-сцены (Кристальная шахта на карте мира) + public static bool Loaded { get; private set; } public static ILive Live; public string SaveKey => "settlement3d"; @@ -51,6 +53,7 @@ public sealed class City3DSave : ISaveParticipant public void Restore(string json) { + Loaded = true; Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson(json); if (Cached != null) { @@ -63,6 +66,7 @@ public sealed class City3DSave : ISaveParticipant public void ResetState() { + Loaded = true; Cached = null; Live?.OnSaveLoaded(); } diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index fc16104..bb41bd1 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -73,6 +73,7 @@ public class GameProgress : MonoBehaviour nextTick = Time.unscaledTime + 1f; EnsureDailyAndWeekly(); ProcessTraining(); + ProcessCrystalMine(); UpdateBuildingsCount(); UpdatePowerResource(); } @@ -154,6 +155,8 @@ public class GameProgress : MonoBehaviour if (Data.crystalTraderNextId < 1) Data.crystalTraderNextId = 1; trader = new CrystalTrader(storageCfg.crystalTraderPrices, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day); trader.Load(Data.crystalTrader); + mine = new CrystalMine(storageCfg.crystalMinePerPeriod, Math.Max(1, storageCfg.crystalMinePeriodDays) * CrystalQuota.Day); + mine.Load(Data.crystalMine); if (Data.mail == null) Data.mail = new List(); if (Data.shopIds == null) Data.shopIds = new List(); if (Data.shopCounts == null) Data.shopCounts = new List(); @@ -381,6 +384,44 @@ public class GameProgress : MonoBehaviour return allowed; } + // ---------------- Кристальная шахта (правила -- CrystalMine) ---------------- + + CrystalMine mine = new CrystalMine(); + + // Работающих шахт сейчас (достроенных) + public int CrystalMinesWorking + { + get + { + var from = City3DGameplay.CrystalMinesActiveFrom(); + int n = 0; + if (from != null) foreach (long t in from) if (t <= GameClock.UnixNowMs) n++; + return n; + } + } + public double CrystalMineSecondsToNext { get { long ms = mine.MsToNext(CrystalMinesWorking); return ms < 0 ? -1 : ms / 1000.0; } } + public int CrystalMinePerPeriod => mine.PerPeriod; + public int CrystalMinePeriodDays => (int)(mine.PeriodMs / CrystalQuota.Day); + + // Раз в секунду: выработка шахт до текущего момента (и офлайн -- при первом расчёте после входа). + // Не входит в недельный PvE-предел. Хранилище полное -- пауза + void ProcessCrystalMine() + { + if (ResourceManager.Instance == null) return; // склада нет -- не считать (время не теряется) + var from = City3DGameplay.CrystalMinesActiveFrom(); + if (from == null) return; // город ещё не загружен + int got = mine.Advance(GameClock.UnixNowMs, from, CrystalRewards.Free(GemsHave, GemsCap)); + Data.crystalMine = mine.ToLine(); + if (got <= 0) return; + suppressTracking++; + ResourceManager.Instance.AddResource(ResourceType.Gems, got); // место проверено в Advance + suppressTracking--; + MarkChanged(); + Save(); // прогресс шахты + if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // и кристаллы -- сразу, без повторного начисления + UIKit.Toast($"Кристальная шахта: +{got} синих кристаллов"); + } + // ---------------- Редкий PvE-торговец (правила -- CrystalTrader) ---------------- CrystalTrader trader = new CrystalTrader(); diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index d8d8432..4eda99e 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -76,6 +76,8 @@ public class ProgressData // Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки" public List crystalTrader = new List(); public int crystalTraderNextId = 1; + // Кристальная шахта: "прогресс мс|до какого момента посчитано мс" (ответ 06.11, п. 3) + public string crystalMine = ""; public float[] dailyCollected = new float[8]; public int dailyBattles; public int dailyTroops; diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs b/Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs new file mode 100644 index 0000000..e55564e --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +// КРИСТАЛЬНАЯ ШАХТА: выработка синих кристаллов (Регламент редкости синих кристаллов от 05.11.2026, разд. 2.1; +// Регламент валют от 05.11, разд. 5.2 и 8.1; «Ответ Володе» от 06.11, п. 3). Документ: Docs/Modules/Economy.md. +// +// - Каждая работающая шахта -- PerPeriod (ТЕСТ 3) кристаллов за PeriodMs (7 РЕАЛЬНЫХ суток; не игровые часы ×12), +// равномерно: один кристалл за PeriodMs / PerPeriod. Шахты (до двух) складываются. +// - Шахта работает с момента, когда достроена (activeFromMs): стройка, закончившаяся офлайн, добывает с её конца. +// - Хранилище полное -- пауза: прогресс не растёт, скрытого запаса нет. Хранилище заполнилось в ходе расчёта -- +// шахта встаёт сразу после последнего поместившегося кристалла (прогресс 0). Освободилось место -- выработка идёт +// дальше с оставшегося прогресса, без начисления за время паузы. +// - Офлайн: досчёт по меткам при входе; уже посчитанное время второй раз не начисляется; часы назад (сброс +// DEV-перемотки) -- метка не уходит назад, ничего не начисляется. +// - В недельный PvE-предел не входит (отдельный учёт). +// Без Unity: те же правила будут на сервере и в тестах. +public sealed class CrystalMine +{ + public readonly int PerPeriod; + public readonly long PeriodMs; + + // Прогресс к следующему кристаллу (мс работы одной шахты) и до какого момента посчитано + public long ProgressMs { get; private set; } + public long LastMs { get; private set; } + + public CrystalMine(int perPeriod = 3, long periodMs = 7 * CrystalQuota.Day) + { + PerPeriod = Math.Max(1, perPeriod); + PeriodMs = Math.Max(1, periodMs); + } + + public long MsPerCrystal => Math.Max(1, PeriodMs / PerPeriod); + + // Досчитать до nowMs. mineActiveFromMs -- по шахте: с какого момента она работает (long.MinValue -- давно). + // freeSpace -- сколько кристаллов помещается в хранилище. Возвращает, сколько кристаллов начислить + public int Advance(long nowMs, IEnumerable mineActiveFromMs, int freeSpace) + { + if (LastMs == 0) { LastMs = nowMs; return 0; } // первый расчёт: отсюда и считаем + if (nowMs <= LastMs) return 0; // часы назад или то же время -- ничего + long from = LastMs; + LastMs = nowMs; + if (freeSpace <= 0) return 0; // пауза: хранилище полное, прогресс не растёт + + long work = 0; + if (mineActiveFromMs != null) + foreach (long active in mineActiveFromMs) + { + long start = Math.Max(from, active); + if (start < nowMs) work += nowMs - start; + } + if (work <= 0) return 0; + + long total = ProgressMs + work; + long made = total / MsPerCrystal; + if (made <= freeSpace) + { + ProgressMs = total - made * MsPerCrystal; + return (int)made; + } + ProgressMs = 0; // хранилище заполнилось: шахта встала сразу после последнего кристалла, лишнее не копится + return freeSpace; + } + + // Через сколько мс следующий кристалл при count работающих шахтах (0 шахт -- -1) + public long MsToNext(int count) => count <= 0 ? -1 : Math.Max(0, (MsPerCrystal - ProgressMs + count - 1) / count); + + public void Load(string line) + { + ProgressMs = 0; LastMs = 0; + if (string.IsNullOrEmpty(line)) return; + var p = line.Split('|'); + if (p.Length >= 2 + && long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long progress) + && long.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last)) + { + ProgressMs = Math.Max(0, Math.Min(progress, MsPerCrystal - 1)); + LastMs = Math.Max(0, last); + } + } + + public string ToLine() => ProgressMs.ToString(CultureInfo.InvariantCulture) + "|" + LastMs.ToString(CultureInfo.InvariantCulture); +} diff --git a/Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs.meta b/Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs.meta new file mode 100644 index 0000000..2bf630d --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/CrystalMine.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f3b9d1e8a2c4e70a6d4b8c1f9e27d36 +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 754f855..98afc69 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -668,3 +668,68 @@ public class CrystalTraderTests Assert.AreEqual(30000, copy.PriceNow(T0 + 8 * Day), "обе вышли из окна -- снова 30 000"); } } + +// Кристальная шахта: 3 синих кристалла за 7 реальных суток (регламент кристаллов 05.11, разд. 2.1 и 6; ответ 06.11, п. 3) +public class CrystalMineTests +{ + const long T0 = 1_800_000_000_000L; + const long Day = CrystalQuota.Day; + static readonly long[] OneMine = { long.MinValue }; + + static CrystalMine Started() + { + var m = new CrystalMine(3, 7 * Day); + m.Advance(T0, OneMine, 50); // первый расчёт -- точка отсчёта + return m; + } + + [Test] + public void Mine_7RealDays_FreeStorage_Exactly3() + { + var m = Started(); + int got = 0; + for (long t = T0; t < T0 + 7 * Day; t += 10 * 60 * 1000) got += m.Advance(t + 10 * 60 * 1000, OneMine, 50 - got); + Assert.AreEqual(3, got, "за 7 реальных суток -- ровно 3"); + } + + [Test] + public void Mine_5RealMinutes_NotWeeklyAmount() + { + var m = Started(); + Assert.AreEqual(0, m.Advance(T0 + 5 * 60 * 1000, OneMine, 50), "5 реальных минут -- не недельный объём (×12 не действует)"); + } + + [Test] + public void Mine_49of50_AtMostOne_50of50_Paused_ThenContinues() + { + var m = Started(); + Assert.AreEqual(1, m.Advance(T0 + 7 * Day, OneMine, 1), "49/50 -- не больше 1"); + Assert.AreEqual(0, m.ProgressMs, "лишнее не копится: шахта встала после последнего поместившегося"); + Assert.AreEqual(0, m.Advance(T0 + 14 * Day, OneMine, 0), "50/50 -- пауза"); + Assert.AreEqual(0, m.ProgressMs, "за паузу прогресс не вырос"); + Assert.AreEqual(1, m.Advance(T0 + 14 * Day + m.MsPerCrystal, OneMine, 10), "место есть -- дальше с оставшегося прогресса, без начисления за паузу"); + } + + [Test] + public void Mine_Reload_And_ClockBack_NoDouble() + { + var m = Started(); + Assert.AreEqual(1, m.Advance(T0 + m.MsPerCrystal, OneMine, 50)); + var copy = new CrystalMine(3, 7 * Day); + copy.Load(m.ToLine()); + Assert.AreEqual(0, copy.Advance(T0 + m.MsPerCrystal, OneMine, 50), "после загрузки то же время -- ничего"); + Assert.AreEqual(0, copy.Advance(T0, OneMine, 50), "часы назад (сброс перемотки) -- ничего"); + Assert.AreEqual(T0 + m.MsPerCrystal, copy.LastMs, "метка не ушла назад"); + } + + [Test] + public void Mine_TwoMines_Double_BuiltOffline_CountsFromCompletion() + { + var m = Started(); + Assert.AreEqual(6, m.Advance(T0 + 7 * Day, new[] { long.MinValue, long.MinValue }, 50), "две шахты -- 6 за 7 суток"); + var late = Started(); + Assert.AreEqual(1, late.Advance(T0 + 3 * Day, new[] { T0 + 3 * Day - late.MsPerCrystal }, 50), + "шахта достроена офлайн -- добывает с конца стройки, а не с начала офлайна"); + Assert.AreEqual(0, Started().Advance(T0 + Day, new long[0], 50), "шахт нет -- ничего"); + } +} diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 2993a1f..04acc37 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -101,13 +101,14 @@ Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 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 почти недостижимы. +- **Редкий 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 почти недостижимы. +- **Кристальная шахта (разд. 2.1 регламента кристаллов, разд. 8.1 регламента валют, ответ 06.11, п. 3).** Здание «Кристалл» (Ратуша 6; вторая шахта — Ратуша 9, ТЕСТ, лимит в `buildingLimits`). Каждая работающая шахта — 3 синих кристалла за 7 РЕАЛЬНЫХ суток, по одному примерно раз в 56 ч (`crystalMinePerPeriod`, `crystalMinePeriodDays`, ТЕСТ); шахты складываются. Работает с конца стройки (и если стройка закончилась офлайн), досчитывается после входа по меткам; уже посчитанное время второй раз не начисляется, часы назад — ничего. Хранилище полное — пауза без скрытого запаса, после освобождения места — с оставшегося прогресса. В недельный PvE-предел не входит. Окно здания: «Работает шахт: N. Следующий кристалл через …» или «Пауза». Вне 3D-сцены (карта мира) шахты считаются по сохранению `settlement3d`. Правила — `Modules/Economy/Rules/CrystalMine.cs`, тесты — `CrystalMineTests`; расчёт — `GameProgress.ProcessCrystalMine` (раз в секунду), сохранение — `progress.json`, `crystalMine`. DEV «Ресурсы 100 000» — обычные ресурсы мимо лимита склада, для проверки. - **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс. - **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов. -**Ещё не сделано:** Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера. +**Ещё не сделано:** космо-кристаллы; торговля и военная добыча — этап мультиплеера. -**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты. +**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты (сейчас 9), лимит золота против цен торговца и расширений. ## Таблица баланса `EconomyConfig` @@ -130,6 +131,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух (вторая — Ратуша 9, ТЕСТ), офлайн по меткам, пауза при полном хранилище, вне недельного предела (`CrystalMine` + 5 тестов, `GameProgress.ProcessCrystalMine`, окно здания). | | 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 с. | -- 2.54.0