diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 57447b0..817060f 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -16,7 +16,7 @@ using UnityEngine.UI; // кнопка "Нанять войска" (панель "Войска"). // - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы. // Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне. -// - У построенных зданий (кроме Ратуши) -- кнопки "Перенести" (режим стройки) и "Снести" (часть цены возвращается). +// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11). // Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям. // Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики. // Пока окно открыто, цифры обновляются сами (раз в полсекунды). @@ -573,64 +573,19 @@ public static class BuildingWindow UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); } - // ---------------- Перенос и снос ---------------- + // ---------------- Перенос (решения 04.11: сноса нет) ---------------- + // Здание можно бесплатно переставить в пределах поселения: уровень, стройка или улучшение сохраняются. + // Снести здание и вернуть ресурсы нельзя -- такой кнопки нет. static void ActionsBlock(RectTransform card, GameObject building) { var id = building.GetComponent(); if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены - var row = UIKit.Row(card, 80, 12); - var move = UIKit.Button(row, "Перенести", UIKit.Theme.Blue, () => + UIKit.Button(card, "Перенести", UIKit.Theme.Blue, () => { Close(); if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building); }, -1, 80, 26); - UIKit.Size(move, -1, 80, 1); - - float share = EconomyConfig.Load().construction.demolishRefund; - var demolish = UIKit.Button(row, "Снести", UIKit.Theme.Red, () => - { - UIKit.Confirm("Снести здание?", - $"«{id.buildingName}» будет снесено. Вернётся {Mathf.RoundToInt(share * 100)}% цены постройки: {RefundText(id.buildingName, share)}.", - "Снести", () => Demolish(building)); - }, -1, 80, 26); - UIKit.Size(demolish, -1, 80, 1); - } - - static ResourceAmount[] Refund(string buildingName, float share) - { - var data = BuildManager.Instance != null ? BuildManager.Instance.FindData(buildingName) : null; - if (data == null || data.cost == null) return new ResourceAmount[0]; - var result = new ResourceAmount[data.cost.Length]; - for (int i = 0; i < result.Length; i++) result[i] = new ResourceAmount(data.cost[i].type, Mathf.Floor(data.cost[i].amount * share)); - return result; - } - - static string RefundText(string buildingName, float share) - { - var parts = new List(); - foreach (var a in Refund(buildingName, share)) if (a.Amount > 0) parts.Add($"{a.Amount:0} {GameData.ResName(a.Type)}"); - return parts.Count > 0 ? string.Join(", ", parts.ToArray()) : "ничего"; - } - - static void Demolish(GameObject building) - { - if (building == null) return; - var id = building.GetComponent(); - if (id == null || string.IsNullOrEmpty(id.buildingName)) return; - string name = id.buildingName; - if (GameServices.TryGet(out IEconomy economy)) - foreach (var a in Refund(name, EconomyConfig.Load().construction.demolishRefund)) - if (a.Amount > 0) economy.Add(a.Type, a.Amount); - - var timer = building.GetComponent(); - if (timer != null) timer.Discard(); // строитель свободен сразу - id.buildingName = null; // сохранение и пересчёт города в этом кадре его уже не видят - UnityEngine.Object.Destroy(building); - Close(); - if (CitySync.Instance != null) CitySync.Instance.SyncNow(); - if (BuildGrid.Instance != null) BuildGrid.Instance.Refresh(); - UIKit.Toast($"Снесено: {name}"); } // Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть diff --git a/Assets/Scripts/App/ConstructionClock.cs b/Assets/Scripts/App/ConstructionClock.cs new file mode 100644 index 0000000..41dd076 --- /dev/null +++ b/Assets/Scripts/App/ConstructionClock.cs @@ -0,0 +1,40 @@ +// Стройка и офлайн (решения 04.11): стройка, которая закончилась, пока игра была закрыта, вступает в силу +// в тот момент офлайн-расчёта, когда время дошло до её конца. Тогда добыча здания (и его новый уровень) +// считается ровно с момента завершения, а не с начала офлайна и не с момента входа в игру. +// +// Как: модуль Населения на каждом шаге расчёта сообщает, до какого момента дошёл (GameTimeSimulatedEvent). +// Здесь завершаются стройки с концом не позже этого момента, и город сразу пересчитывается (CitySync) -- +// следующий шаг добычи идёт уже с новым зданием. Пока расчёт не дошёл до конца стройки, BuildTimer сам её не +// завершает (BuildTimer.MayComplete). Шаг расчёта -- 10 минут, поэтому добыча начинается не раньше конца +// стройки и не позже чем через 10 минут после него: лишнего не начисляется никогда. +public static class ConstructionClock +{ + // До какого момента (мс) расчёт добычи уже дошёл. 0 -- ещё ни разу не считал (сразу после запуска) + public static long SimulatedUntilMs { get; private set; } + + static bool subscribed; + + public static void Init() + { + SimulatedUntilMs = 0; + BuildTimer.MayComplete = endMs => endMs <= SimulatedUntilMs; + if (subscribed) return; + subscribed = true; + EventBus.Subscribe(OnSimulated); + } + + static void OnSimulated(GameTimeSimulatedEvent e) + { + long ms = e.UnixNow * 1000; + if (ms > SimulatedUntilMs) SimulatedUntilMs = ms; // время расчёта только растёт + + bool any = false; + foreach (var timer in BuildTimer.All.ToArray()) + { + if (timer == null || timer.endMs > SimulatedUntilMs) continue; + timer.Complete(); // уровень только растёт, второй раз не завершается + any = true; + } + if (any && CitySync.Instance != null) CitySync.Instance.SyncNow(); + } +} diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index 205d2cd..14d0d51 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -136,8 +136,7 @@ public class EconomyConfig : ScriptableObject public float townHallTimeMultiplier = 2f; [Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")] public int secondsPerGem = 30; - [Tooltip("Снос здания возвращает такую долю цены постройки (0.5 -- половину). ТЕСТ")] - [Range(0f, 1f)] public float demolishRefund = 0.5f; + // Сноса зданий нет (решение 04.11): только бесплатный перенос } [Serializable] @@ -162,6 +161,8 @@ public class EconomyConfig : ScriptableObject public float baseFood = 100f; [Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")] public float perGranaryLevel = 300f; + [Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")] + [Range(0f, 1f)] public float plunderGemsShare = 0.1f; } [Serializable] @@ -292,7 +293,7 @@ public class EconomyConfig : ScriptableObject case ResourceType.Iron: case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels; case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels; - case ResourceType.Gems: return float.PositiveInfinity; // кристаллы не грабятся + case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection) default: return 0f; } } diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 55eaa2b..cbaeb8e 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -83,6 +83,7 @@ public static class GameModules // Стройка и улучшение по времени, строители (01.11, ТЕСТ): числа -- EconomyConfig, раздел "Стройка" BuildTimer.Builders = Mathf.Max(1, config.construction.builders); + ConstructionClock.Init(); // офлайн: стройка вступает в силу в момент своего завершения (решения 04.11) BuildTimer.DurationMs = (townHall, level) => EconomyConfig.Load().ConstructionMs(townHall, level); BuildTimer.Tapped -= OnBuildTimerTapped; BuildTimer.Tapped += OnBuildTimerTapped; diff --git a/Assets/Scripts/App/ResourceProtection.cs b/Assets/Scripts/App/ResourceProtection.cs index cc3b8da..e7cbcf7 100644 --- a/Assets/Scripts/App/ResourceProtection.cs +++ b/Assets/Scripts/App/ResourceProtection.cs @@ -19,9 +19,15 @@ public sealed class ResourceProtection : IResourceProtection return EconomyConfig.Load().GetProtected(type, WarehouseLevels, GranaryLevels); } - public float Lootable(ResourceType type) + public float Lootable(ResourceType type) => Lootable(type, false); + + // Кристаллы: без «Грабежа» -- 0; с ним -- не больше доли запаса (EconomyConfig, ТЕСТ, точные ограничения -- позже) + public float Lootable(ResourceType type, bool attackerHasPlunder) { if (!GameServices.TryGet(out IEconomy economy)) return 0f; - return Mathf.Max(0f, economy.Get(type) - Protected(type)); + float have = Mathf.Max(0f, economy.Get(type)); + if (type == ResourceType.Gems) + return attackerHasPlunder ? Mathf.Floor(have * Mathf.Clamp01(EconomyConfig.Load().protection.plunderGemsShare)) : 0f; + return Mathf.Max(0f, have - Protected(type)); } } diff --git a/Assets/Scripts/Meta/Build/BuildModeController.cs b/Assets/Scripts/Meta/Build/BuildModeController.cs index 06d1c31..6fd2e8e 100644 --- a/Assets/Scripts/Meta/Build/BuildModeController.cs +++ b/Assets/Scripts/Meta/Build/BuildModeController.cs @@ -219,6 +219,10 @@ public class BuildModeController : MonoBehaviour if (moving != null) { var building = moving; + var id = building != null ? building.GetComponent() : null; + if (id == null || string.IsNullOrEmpty(id.buildingName)) { CancelGhost(); return; } // здания уже нет + // Перенос бесплатный: меняется только место. Уровень, стройка и улучшение остаются у того же здания, + // ресурсы не тратятся и не возвращаются (решения 04.11) building.transform.position = ghost.PrefabPosition; CancelGhost(); // снимает "игнор" клеток -- здание уже на новом месте UIKit.Toast("Перенесено: " + d.buildingName); diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index e2a4d32..1a6b64f 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -25,10 +25,13 @@ public interface IEconomy // Защита ресурсов от грабежа (01.11, ТЕСТ). Сколько ресурса нельзя отнять в набеге: часть запаса прячут // Склады и Амбары. Добыча набега по ресурсу = Lootable. Реализует слой приложения (App/ResourceProtection) // по зданиям города; модули Боя и Мира берут отсюда, сколько можно унести у города. +// Кристаллы (решение 04.11): недоступны нападающему, пока он не изучил технологию «Грабёж». +// Технологий в игре ещё нет -- изучена ли она, передаёт тот, кто считает набег (attackerHasPlunder). public interface IResourceProtection { - float Protected(ResourceType type); // сколько спрятано (кристаллы -- всё) - float Lootable(ResourceType type); // сколько можно унести: max(0, запас - спрятано) + float Protected(ResourceType type); // сколько спрятано от нападающего без «Грабежа» + float Lootable(ResourceType type); // сколько можно унести без «Грабежа» (кристаллы -- 0) + float Lootable(ResourceType type, bool attackerHasPlunder); // с учётом технологии нападающего } // Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService). diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 7310dc1..d7bf66a 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent public long UnixNow; } +// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11). +// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick. +// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг +// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна. +public struct GameTimeSimulatedEvent +{ + public long UnixNow; +} + // ---------------- Население ---------------- // Число людей изменилось (прирост, загрузка, сброс, изменение войска) public struct PopulationChangedEvent diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs index d9f2567..209d125 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -221,17 +221,18 @@ public sealed class PopulationService : IPopulation, ISaveParticipant { lastTickUnix = unixNow; // первый расчёт: отсюда и считаем Changed(); + SimulatedUntil(unixNow); return; } long elapsed = unixNow - lastTickUnix; if (elapsed < 0) { - // Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас" - lastTickUnix = unixNow; + // Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне). Метку НЕ двигаем назад: + // это время уже посчитано, и когда часы снова дойдут до него, оно не начислится второй раз (04.11). return; } - if (elapsed == 0) return; + if (elapsed == 0) { SimulatedUntil(unixNow); return; } long max = (long)rules.MaxOfflineHours * SecondsPerHour; if (elapsed > max) elapsed = max; @@ -239,6 +240,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant Simulate(unixNow - elapsed, elapsed); lastTickUnix = unixNow; PruneFamine(unixNow); + SimulatedUntil(unixNow); + } + + // Расчёт дошёл до момента: то, что случается по времени (стройка закончилась), применяется сейчас (GameTimeSimulatedEvent) + static void SimulatedUntil(long unixNow) + { + EventBus.Publish(new GameTimeSimulatedEvent { UnixNow = unixNow }); } // ---------------- Расчёт ---------------- @@ -260,6 +268,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant remaining -= step; double hours = step / (double)SecondsPerHour; + // Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу, + // и добыча этого шага считается уже с ними (а не с начала офлайна) + SimulatedUntil(stepStart); + // 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест production?.Produce(hours); diff --git a/Assets/Scripts/Modules/Settlement/BuildTimer.cs b/Assets/Scripts/Modules/Settlement/BuildTimer.cs index ec37add..3510282 100644 --- a/Assets/Scripts/Modules/Settlement/BuildTimer.cs +++ b/Assets/Scripts/Modules/Settlement/BuildTimer.cs @@ -8,7 +8,9 @@ using UnityEngine; // (не добывает, не даёт жильё и места, Казармы не учат). Тап по нему -- окно стройки. // - Улучшение (Kind.Upgrade): здание работает на старом уровне, новый уровень -- когда время выйдет. // - Одновременно идёт не больше Builders строек (строители). Ратуша -- тоже стройка. -// - Время -- часы игры (GameClock.UnixNowMs): идёт и пока игра закрыта, и при перемотке DEV. +// - Время -- реальное (GameClock.UnixNowMs): идёт и пока игра закрыта, и при перемотке DEV. +// - Офлайн (04.11): метка конца хранится в сохранении; после входа стройка завершается в тот момент расчёта +// добычи, когда время дошло до её конца (App/ConstructionClock) -- добыча здания идёт с момента завершения. // - Сохраняет модуль Поселения вместе со зданием (startMs, endMs, вид, целевой уровень). // Сколько длится стройка и сколько строителей -- задаёт слой приложения (EconomyConfig): модуль чисел не знает. public class BuildTimer : MonoBehaviour @@ -19,6 +21,11 @@ public class BuildTimer : MonoBehaviour // Задаёт слой приложения (App/GameModules) из EconomyConfig public static int Builders = 2; + // Можно ли завершить стройку, которая кончилась в этот момент (мс). Слой приложения отвечает "да", только когда + // расчёт добычи уже дошёл до этого момента (GameTimeSimulatedEvent): иначе после офлайна здание считалось бы + // готовым с самого начала офлайна. null -- завершать сразу. + public static Func MayComplete; + // Длительность в мс: (это Ратуша?, до какого уровня; 1 -- постройка). 0 -- мгновенно public static Func DurationMs = (townHall, level) => 0; public static int Busy => All.Count; @@ -81,13 +88,14 @@ public class BuildTimer : MonoBehaviour var townHall = GetComponent(); if (townHall != null) { - townHall.currentLevel = Mathf.Min(townHall.maxLevel, targetLevel); + // уровень только растёт: повторное или устаревшее завершение не понизит и не "докупит" уровни + townHall.currentLevel = Mathf.Min(townHall.maxLevel, Mathf.Max(townHall.currentLevel, targetLevel)); EventBus.Publish(new TownHallLevelChangedEvent { Level = townHall.currentLevel }); } else { var leveled = GetComponent(); - if (leveled != null) leveled.Level = targetLevel; + if (leveled != null) leveled.Level = Mathf.Max(leveled.Level, targetLevel); } } Completed?.Invoke(this); @@ -122,7 +130,7 @@ public class BuildTimer : MonoBehaviour void Update() { - if (!finished && GameClock.UnixNowMs >= endMs) Complete(); + if (!finished && GameClock.UnixNowMs >= endMs && (MayComplete == null || MayComplete(endMs))) Complete(); } // ---------------- Здание, которое строится, не работает ---------------- diff --git a/Assets/Scripts/Modules/Settlement/SettlementModule.cs b/Assets/Scripts/Modules/Settlement/SettlementModule.cs index 6fd6fb2..a56f146 100644 --- a/Assets/Scripts/Modules/Settlement/SettlementModule.cs +++ b/Assets/Scripts/Modules/Settlement/SettlementModule.cs @@ -104,7 +104,8 @@ public static class SettlementModule TownHall.Instance.currentLevel = cached.townHallLevel; var oldTimer = BuildTimer.Of(TownHall.Instance.gameObject); if (oldTimer != null) oldTimer.Cancel(); - if (cached.townHallTimerEndMs > 0) + // Проверка таймера из файла: улучшение до уровня выше текущего, конец не раньше начала + if (cached.townHallTimerEndMs > 0 && cached.townHallTimerTargetLevel > cached.townHallLevel) BuildTimer.Begin(TownHall.Instance.gameObject, BuildTimer.Kind.Upgrade, cached.townHallTimerTargetLevel, cached.townHallTimerStartMs, cached.townHallTimerEndMs); } @@ -132,7 +133,10 @@ public static class SettlementModule if (leveled != null) leveled.Level = entry.level; // Стройка или улучшение продолжается (если время уже вышло -- закончится в первом же кадре) - if (entry.timerKind == (int)BuildTimer.Kind.Build || entry.timerKind == (int)BuildTimer.Kind.Upgrade) + // Проверка таймера из файла: постройка -- только для 1 уровня, улучшение -- только до уровня выше текущего + bool validBuild = entry.timerKind == (int)BuildTimer.Kind.Build && entry.level <= 1; + bool validUpgrade = entry.timerKind == (int)BuildTimer.Kind.Upgrade && entry.timerTargetLevel > Mathf.Max(1, entry.level); + if ((validBuild || validUpgrade) && entry.timerEndMs > 0) BuildTimer.Begin(built, (BuildTimer.Kind)entry.timerKind, Mathf.Max(1, entry.timerTargetLevel), entry.timerStartMs, entry.timerEndMs); diff --git a/Assets/Scripts/Modules/Tests/PopulationTests.cs b/Assets/Scripts/Modules/Tests/PopulationTests.cs index 3c40f6b..00858df 100644 --- a/Assets/Scripts/Modules/Tests/PopulationTests.cs +++ b/Assets/Scripts/Modules/Tests/PopulationTests.cs @@ -456,7 +456,7 @@ public class PopulationTests } [Test] - public void ClockGoesBack_NothingHappens_ThenContinues() + public void ClockGoesBack_NothingHappens_CountedTimeNotCountedAgain() { economy.Set(ResourceType.Food, 1000); var p = StartCity(); @@ -465,8 +465,12 @@ public class PopulationTests Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "время назад -- ничего не съедено"); Assert.AreEqual(300, p.Total); + // Решения 04.11: метка последнего расчёта не уходит назад -- уже посчитанное время второй раз не считается p.Tick(T0 - 5000 + Hour); - Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01); + Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "часы ещё не дошли до уже посчитанного -- ничего"); + + p.Tick(T0 + Hour); + Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "дальше метки -- ровно один час"); } [Test] diff --git a/Assets/Scripts/Modules/Tests/ProductionTests.cs b/Assets/Scripts/Modules/Tests/ProductionTests.cs index ceaac09..847641a 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -245,4 +245,96 @@ public class ProductionTests Assert.AreEqual(40, copy.Slots(WorkJob.Stonecutter)); Assert.AreEqual(1.1f, copy.Efficiency(WorkJob.Stonecutter), 0.0001f); } + + // ---------------- Офлайн по временным меткам (решения 04.11) ---------------- + // Стройка, закончившаяся в офлайне, вступает в силу в момент расчёта, когда время дошло до её конца + // (GameTimeSimulatedEvent). В игре это делает App/ConstructionClock; здесь -- подписка в тесте. + + void CompleteUpgradeAt(long atUnix, List> after) + { + bool done = false; + EventBus.Subscribe(e => + { + if (done || e.UnixNow < atUnix) return; + done = true; + production.SetBuildings(after); + }); + } + + [Test] + public void Offline_UpgradeFinishedMidway_ProducesNewRateOnlyFromCompletion() + { + economy.Set(ResourceType.Food, 100000); + economy.Set(ResourceType.Wood, 0); + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час, на 2 уровне -- 66 + CompleteUpgradeAt(T0 + 2 * Hour, Buildings((WorkJob.Woodcutter, 2))); + + population.Tick(T0 + 4 * Hour); // 4 часа офлайна, улучшение закончилось через 2 часа + + Assert.AreEqual(2 * 60 + 2 * 66, economy.Get(ResourceType.Wood), 0.01f, + "2 часа по 60 + 2 часа по 66, а не все 4 часа по новой норме"); + } + + [Test] + public void Offline_FinishedInsideStep_CountsFromNextStep_NeverEarlier() + { + economy.Set(ResourceType.Food, 100000); + economy.Set(ResourceType.Wood, 0); + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Woodcutter, 30); + CompleteUpgradeAt(T0 + 2 * Hour + 5 * 60, Buildings((WorkJob.Woodcutter, 2))); // посреди 10-минутного шага + + population.Tick(T0 + 4 * Hour); + + float wood = economy.Get(ResourceType.Wood); + Assert.LessOrEqual(wood, 125 / 60f * 60 + 115 / 60f * 66 + 0.01f, "не больше, чем если бы новая норма шла ровно с конца стройки"); + Assert.AreEqual(130 / 60f * 60 + 110 / 60f * 66, wood, 0.01f, "с ближайшего шага расчёта после конца стройки"); + } + + [Test] + public void Tick_SameMomentTwice_NoExtraResources() + { + economy.Set(ResourceType.Food, 100000); + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Woodcutter, 30); + population.Tick(T0 + Hour); + float wood = economy.Get(ResourceType.Wood); + + population.Tick(T0 + Hour); + population.Tick(T0 + Hour - 60); // часы ушли назад -- ничего не начисляется + population.Tick(T0 + Hour); + Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f); + } + + [Test] + public void Restore_SameMoment_NoSecondCredit() + { + economy.Set(ResourceType.Food, 100000); + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Woodcutter, 30); + population.Tick(T0 + Hour); + float wood = economy.Get(ResourceType.Wood); + + var copy = new PopulationService(new PopulationRules()); + copy.Restore(population.Capture()); // как загрузка сохранения: метка последнего расчёта та же + GameServices.Register(copy); + copy.Tick(T0 + Hour); + Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f, "загрузка не начисляет то же время второй раз"); + + copy.Tick(T0 + 2 * Hour); + Assert.AreEqual(wood + 60, economy.Get(ResourceType.Wood), 0.01f, "а следующий час -- ровно один раз"); + } + + [Test] + public void SimulatedEvents_GoForwardStepByStep_EndAtNow() + { + var times = new List(); + EventBus.Subscribe(e => times.Add(e.UnixNow)); + population.Tick(T0 + Hour); + + Assert.AreEqual(T0 + Hour, times[times.Count - 1], "последнее событие -- момент Tick"); + for (int i = 1; i < times.Count; i++) Assert.GreaterOrEqual(times[i], times[i - 1], "время расчёта не идёт назад"); + Assert.AreEqual(7, times.Count, "6 шагов по 10 минут + конец"); + } } diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index d57f593..f2a1c0d 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -36,7 +36,7 @@ | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход; начать поход «Разведка» (`TryStartScout`). | | `IWorldMap` | Мир (`MarchManager`) | Для Разведки: границы карты, где поселение, все армии в момент времени (`ArmyOnMap`). | | `IIntelligence` | Разведка (`IntelligenceService`) | Туман (`GetVisibility`), «Обзор», известные чужие армии (`KnownContact`), `ObserveUntil`. | -| `IResourceProtection` | Город — Склады и Амбары (`App/ResourceProtection`, регистрирует `App/GameModules`) | Сколько ресурса спрятано от грабежа (`Protected`) и сколько можно унести в набеге (`Lootable`). 01.11, ТЕСТ. | +| `IResourceProtection` | Город — Склады и Амбары (`App/ResourceProtection`, регистрирует `App/GameModules`) | Сколько ресурса спрятано от грабежа (`Protected`) и сколько можно унести в набеге (`Lootable`; кристаллы — только с технологией «Грабёж»: `Lootable(type, attackerHasPlunder)`). 01.11 / 04.11, ТЕСТ. | | `IHospital` | Город — здание «Госпиталь» (`HospitalBeds`, регистрирует `App/GameModules`) | Мест для лечения (`Beds`) по уровню самого прокачанного госпиталя; 0 — госпиталя нет. | | `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. | | `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. | @@ -62,6 +62,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Событие `GameTimeSimulatedEvent` (расчёт дошёл до момента — для стройки в офлайне). `IResourceProtection.Lootable(type, attackerHasPlunder)` — кристаллы и технология «Грабёж». Существующие контракты не менялись. | | 01.11.2026 | Claude | Контракт `IResourceProtection` (защита ресурсов от грабежа): для будущих набегов на города — Бой и Мир берут отсюда, сколько можно унести. | | 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. | | 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). | diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index b37736a..1a0a2a4 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -109,6 +109,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Решения 04.11: кристаллы грабятся только нападающим с технологией «Грабёж» (`IResourceProtection.Lootable(type, attackerHasPlunder)`; с ней — не больше `plunderGemsShare` запаса, ТЕСТ 10%, точные ограничения — позже). Защита остальных ресурсов без изменений. Из `EconomyConfig` убран возврат при сносе. Офлайн: добыча здания, достроенного в офлайне, — с момента завершения; уже посчитанное время второй раз не начисляется (тесты в `ProductionTests`). | | 01.11.2026 | Claude | `EconomyConfig`: разделы «Стройка» (время, строители, ускорение, возврат при сносе), «Лимит зданий по уровню Ратуши», «Защита ресурсов от грабежа» — всё ТЕСТ. Защита: без Складов 100 каждого ресурса, +200 за уровень Склада; еда 100, +300 за уровень Амбара; кристаллы не грабятся. Контракт `IResourceProtection` (`App/ResourceProtection`), в окне Склада и Амбара — «спрятано / можно унести». | | 31.10.2026 | Claude | Новые места после стройки и улучшения больше не заполняются сами (`App/CitySync`), только стартовая расстановка один раз. | | 31.10.2026 | Claude | Голод: добыча всего, кроме еды, ×0.5 (`ProductionRules.FamineNonFoodMultiplier`, ТЕСТ). Тест `Famine_HalvesNonFoodProduction_…`. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 35029f6..d3c31dd 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -20,7 +20,7 @@ - **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру. - **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены. - **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали. -- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас». +- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, ничего не считается, а метка последнего расчёта назад **не** уходит: когда часы снова дойдут до уже посчитанного, второй раз оно не начислится (решения 04.11). В начале каждого шага и в конце `Tick` модуль публикует `GameTimeSimulatedEvent` — до какого момента дошёл расчёт; по нему стройка, закончившаяся офлайн, вступает в силу ровно в свой момент (`App/ConstructionClock`). - **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления. ## Контракт `IPopulation` @@ -88,7 +88,7 @@ - упёрлись в предел — прирост стоит, Ратуша выше — идёт; предел не превышается; - офлайн ограничен 48 часами; - частые расчёты дают то же, что один большой; -- часы назад — ничего не ломается; +- часы назад — ничего не ломается, уже посчитанное время не начисляется второй раз; - перемотка идёт через те же правила; - сохранение; - стартовый набор — один раз; @@ -112,6 +112,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude (Валдемар) | Решения 04.11 (офлайн по временным меткам): событие `GameTimeSimulatedEvent` на каждом шаге расчёта и в конце `Tick` — стройка, закончившаяся офлайн, вступает в силу в свой момент, добыча с момента завершения. Часы назад: метка последнего расчёта не уходит назад — закрыто повторное начисление. Тесты в `ProductionTests` (офлайн-стройка, повторный Tick, загрузка), обновлён тест про часы назад. Побочный эффект: после «DEV +10 часов» и перезапуска расчёт стоит, пока реальное время не догонит перемотку. | | 01.11.2026 | Claude (Саша) | Раненые считаются людьми: `Soldiers` = в городе + в походах + `IArmy.WoundedTotal` (решения 31.10, п. 7). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). | | 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. | | 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index bcc41e2..b3fa8c0 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude (Валдемар) | Решения Димы 04.11: снос зданий убран (только бесплатный перенос), кристаллы грабятся только с технологией «Грабёж», офлайн по временным меткам — добыча с момента завершения стройки, защита от повторного начисления (метка расчёта не уходит назад). +5 тестов. Обновлены Settlement, Economy, Core. | | 01.11.2026 | Claude (Валдемар) | Поселение: стройка и улучшение по времени, 2 строителя, ускорение; лимит зданий по Ратуше; перенос и снос; защита ресурсов от грабежа (`IResourceProtection`). Терем богатыря и герой в городе. Казармы: найм карточками в ряд. Числа — ТЕСТ. Обновлены Settlement, Economy, Core. | | 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. | | 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 068d591..edfbb47 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -26,15 +26,18 @@ 4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена». 5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется. -## Стройка по времени, лимиты, перенос и снос (01.11.2026, числа — ТЕСТ) +## Стройка по времени, лимиты, перенос (01.11.2026, решения 04.11; числа — ТЕСТ) +- **Время реальное** (решение 04.11): 60 с стройки — 60 реальных секунд, «игровой час» к стройке не относится. Развитие долгосрочное: позже — тиры T1/T2/T3 и внутри них уровни с растущим временем (шкалу согласуют отдельно). - **Стройка и улучшение идут по времени** (`BuildTimer`). Постройка — `buildSeconds` (60 с), улучшение до уровня N — `upgradeSecondsToLevel` (60 с … 2 ч), Ратуша ×2. Время — часы игры (`GameClock.UnixNowMs`): идёт и в офлайне, и при перемотке DEV. - **Пока здание строится,** оно полупрозрачное и не работает: его скрипты выключены, `App/CitySync` его не считает (нет мест, жилья, лимитов; Казармы не учат, Терем без героя). При улучшении здание работает на старом уровне, новый — когда время выйдет. - **Строители:** одновременно не больше `builders` (2) строек и улучшений, Ратуша тоже занимает строителя. - **Ускорение:** 1 кристалл за `secondsPerGem` (30) секунд оставшегося времени — кнопка в окне здания. - **Лимит зданий по уровню Ратуши** (`EconomyConfig.buildingLimits`): в списке стройки «построено 2 / 3», сверх лимита — «Лимит: больше на Ратуше N ур.». Здания не из таблицы — без лимита. В окне Ратуши — каких зданий станет больше на следующем уровне. - **Перенос:** кнопка «Перенести» в окне здания — режим стройки с призраком на месте здания, «Сюда» — здание переезжает бесплатно (`BuildModeController.StartMove`, `BuildGrid.IgnoredBuilding`). Стройка или улучшение продолжаются. -- **Снос:** кнопка «Снести» (с подтверждением) — возвращается `demolishRefund` (50%) цены постройки; строитель, если шла стройка, освобождается сразу. Ратушу не сносят и не переносят. +- **Сноса нет** (решение 04.11): удалить здание и вернуть ресурсы нельзя. Перенос — бесплатно, в пределах поселения, уровень и текущие стройка или улучшение сохраняются. Ратушу не переносят. +- **Офлайн по временным меткам** (решение 04.11): в сохранении хранится момент конца стройки. После входа стройка вступает в силу в тот момент офлайн-расчёта добычи, когда время дошло до её конца (`App/ConstructionClock`, событие `GameTimeSimulatedEvent` от Населения, `BuildTimer.MayComplete`). Добыча нового здания или уровня идёт с момента завершения (точность — шаг расчёта 10 минут, никогда не раньше конца стройки). +- **Проверки:** таймер из файла принимается, только если он осмыслен (постройка — у здания 1 уровня, улучшение — до уровня выше текущего); завершение только повышает уровень, повторное ничего не даёт; перенос не тратит и не возвращает ресурсы. ## Контракт `ISettlement` @@ -92,6 +95,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Решения 04.11: снос убран, остался бесплатный перенос; офлайн по временным меткам — стройка, закончившаяся офлайн, вступает в силу в момент завершения внутри расчёта добычи (`ConstructionClock`, `GameTimeSimulatedEvent`, `BuildTimer.MayComplete`); проверки таймеров из сохранения; уровень при завершении только растёт. | | 01.11.2026 | Claude | Стройка и улучшение по времени (`BuildTimer`), 2 строителя, ускорение за кристаллы, офлайн; лимит зданий по уровню Ратуши; перенос и снос зданий из окна. Сохранение: поля таймера у зданий и Ратуши (старые сохранения читаются). Терем богатыря (`HeroCastle`) и гуляющий герой. Числа — ТЕСТ, `EconomyConfig`. | | 01.11.2026 | Claude (Саша) | Новое здание «Госпиталь» в списке стройки (`Meta/Build/HospitalBootstrap.cs`, как Казармы): уровни I–V сохраняются как у остальных (`ILeveledBuilding`), тап — окно госпиталя. Места — в `HospitalConfig`. Арта нет: копия обычного здания светло-голубого цвета. | | 01.11.2026 | Claude (Саша) | Для госпиталя (решения 31.10, п. 7) в ядре есть контракт `IHospital.Beds` — его будет регистрировать здание госпиталя (уровни I–V, 20–60 мест). Здания пока нет. |