From 157d3ba5109a6b3b127f146c4f184af5553503b6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:31:47 +0100 Subject: [PATCH] =?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 с. |