diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 5309966..6a36e66 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -1,22 +1,35 @@ +using System; +using System.Collections.Generic; using TMPro; using UnityEngine; +using UnityEngine.UI; -// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV. -// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья); -// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час; -// - внизу: "Свободно: N из M". -// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи. +// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь). +// У каждого здания своё окно: +// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+", +// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена; +// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена; +// - Дом: сколько жилья даёт, население / предел, улучшение и цена; +// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения; +// - Кристалл: где добывается, запас / лимит; +// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска), +// кнопка "Нанять войска" (панель "Войска"). +// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям. +// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики. +// Пока окно открыто, цифры обновляются сами (раз в полсекунды). public static class BuildingWindow { static GameObject root; - static GameObject focus; // здание, по которому тапнули (null -- просто список рабочих) + static GameObject focus; // здание, по которому тапнули (null -- список рабочих) + static readonly List live = new List(); // что обновлять, пока окно открыто public static void OnWorkplaceTapped(ResourceCollector building) { Open(building != null ? building.gameObject : null); } public static void OnStorageTapped(StorageBuilding building) { Open(building != null ? building.gameObject : null); } + public static void OnTownHallTapped(TownHall townHall) { Open(townHall != null ? townHall.gameObject : null); } public static void Open(GameObject building) { - // В режиме стройки тап -- это выбор участка, а не здания + // В режиме стройки тап -- это выбор места, а не здания if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return; focus = building; Rebuild(); @@ -24,135 +37,440 @@ public static class BuildingWindow public static void Close() { - if (root != null) Object.Destroy(root); + if (root != null) UnityEngine.Object.Destroy(root); root = null; focus = null; + live.Clear(); } static void Rebuild() { - if (root != null) Object.Destroy(root); + if (root != null) UnityEngine.Object.Destroy(root); + live.Clear(); var card = UIKit.Modal(TitleFor(focus), out root); + root.AddComponent(); - if (focus != null) BuildingSection(card, focus); - WorkersSection(card); + var rc = focus != null ? focus.GetComponent() : null; + var storage = focus != null ? focus.GetComponent() : null; + var townHall = focus != null ? focus.GetComponent() : null; + var barracks = focus != null ? focus.GetComponent() : null; + + if (focus == null) WorkersList(card); + else if (townHall != null) TownHallSection(card, townHall); + else if (barracks != null) BarracksSection(card, barracks); + else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc); + else if (rc != null && rc.IsHouse) HouseSection(card, rc); + else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card); + else if (storage != null) StorageSection(card, storage); UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28); } + internal static void Tick() + { + if (root == null) return; + if (focus == null && FocusWasDestroyed) { Close(); return; } + foreach (var update in live) update(); + } + + static bool focusSet; + static bool FocusWasDestroyed => focusSet && focus == null; + static string TitleFor(GameObject building) { + focusSet = building != null; if (building == null) return "РАБОЧИЕ"; + var townHall = building.GetComponent(); + if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР."; var id = building.GetComponent(); var leveled = building.GetComponent(); string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name; return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper(); } - // ---------------- Здание: что даёт и улучшение ---------------- + // ---------------- Добывающее здание ---------------- - static void BuildingSection(RectTransform card, GameObject building) + static void WorkplaceSection(RectTransform card, ResourceCollector rc) + { + if (!WorkJobs.TryFromResource(rc.resourceType, out WorkJob job)) return; + var production = GameModules.Production; + if (production == null || !GameServices.TryGet(out IPopulation population) || !GameServices.TryGet(out IEconomy economy)) + { + UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center); + return; + } + ResourceType res = WorkJobs.Resource(job); + string resName = GameData.ResName(res).ToLower(); + double norm = production.Rules.PerWorkerPerHour[(int)job]; + + // Что даёт это здание + int slots = production.BuildingSlots(rc.level); + double eff = production.BuildingEfficiency(rc.level); + UIKit.Text(card, $"Здание даёт {slots} мест, норма ×{eff:0.##} — до {slots * norm * eff:0.#} {resName} в час при полном штате.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + // Рабочие профессии и кнопки + Header(card, $"Рабочие: {WorkJobs.Name(job).ToLower()}"); + Live(card, () => $"{population.GetWorkers(job)} / {production.Slots(job)} мест в городе • свободных жителей: {population.FreeWorkers}", + 26, UIKit.Theme.Text); + var row = UIKit.Row(card, 72, 8); + StepButton(row, "−5", job, -5); + StepButton(row, "−1", job, -1); + StepButton(row, "+1", job, +1); + StepButton(row, "+5", job, +5); + + // Добыча и склад + Header(card, "Добыча и склад"); + Live(card, () => + { + float rate = production.RatePerHour(res); + string famine = population.IsStarving ? " голод: добыча ×0.5" : ""; + return $"Сейчас: +{rate:0.#} {resName} в час{famine}"; + }, 26, UIKit.Theme.Text); + StorageBar(card, economy, res); + Live(card, () => FillTimeText(economy, res, production.RatePerHour(res)), 22, UIKit.Theme.TextDim); + + // Улучшение + int nextSlots = production.BuildingSlots(rc.level + 1); + double nextEff = production.BuildingEfficiency(rc.level + 1); + UpgradeBlock(card, rc.level, + $"мест {slots} → {nextSlots}, норма ×{eff:0.##} → ×{nextEff:0.##}", + () => rc.Level = rc.level + 1); + + UIKit.Button(card, "Все рабочие", UIKit.Theme.Blue, () => Open(null), -1, 70, 24); + } + + // ---------------- Дом ---------------- + + static void HouseSection(RectTransform card, ResourceCollector house) + { + int perLevel = EconomyConfig.Load().population.housingPerHouseLevel; + UIKit.Text(card, $"Дом даёт +{perLevel * house.level} к пределу жилья (+{perLevel} за уровень).", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IPopulation population)) + { + Header(card, "Население"); + LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f, + () => $"{population.Total} / {population.HousingCap} человек", UIKit.Theme.Blue); + Live(card, () => population.Total >= population.HousingCap + ? "Предел жилья: прирост стоит, нужны Дома или Ратуша выше" + : $"Прирост: +{population.GrowthPerHour:0.#} человека в час", 22, UIKit.Theme.TextDim); + } + UpgradeBlock(card, house.level, $"жильё +{perLevel * house.level} → +{perLevel * (house.level + 1)}", () => house.Level = house.level + 1); + } + + // ---------------- Кристалл ---------------- + + static void CrystalSection(RectTransform card) + { + UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems); + } + + // ---------------- Склад и Амбар ---------------- + + static void StorageSection(RectTransform card, StorageBuilding storage) + { + var s = EconomyConfig.Load().storage; + bool warehouse = storage.kind == StorageBuilding.Kind.Warehouse; + float perLevel = warehouse ? s.perWarehouseLevel : s.perGranaryLevel; + UIKit.Text(card, warehouse + ? $"Склад даёт +{perLevel * storage.level:0} к лимиту дерева, камня, металла и золота." + : $"Амбар даёт +{perLevel * storage.level:0} к лимиту еды.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + if (GameServices.TryGet(out IEconomy economy)) + { + Header(card, "Запас / лимит"); + if (warehouse) + { + StorageBar(card, economy, ResourceType.Wood); + StorageBar(card, economy, ResourceType.Stone); + StorageBar(card, economy, ResourceType.Iron); + StorageBar(card, economy, ResourceType.Gold); + } + else StorageBar(card, economy, ResourceType.Food); + UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + + UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}", + () => storage.Level = storage.level + 1); + } + + // ---------------- Ратуша ---------------- + + static void TownHallSection(RectTransform card, TownHall townHall) { var config = EconomyConfig.Load(); - var rc = building.GetComponent(); - var storage = building.GetComponent(); - var leveled = building.GetComponent(); - string info; + int level = townHall.currentLevel; + int next = level + 1; + var p = config.population; - if (rc != null && WorkJobs.TryFromResource(rc.resourceType, out WorkJob job) && GameModules.Production != null) - { - var p = GameModules.Production; - info = $"Здание даёт {p.BuildingSlots(rc.level)} мест, норма ×{p.BuildingEfficiency(rc.level):0.##}.\n" + - $"Всего мест «{WorkJobs.Name(job)}» в городе: {p.Slots(job)}."; - } - else if (rc != null && rc.IsHouse) - { - int perLevel = config.population.housingPerHouseLevel; - string cap = GameServices.TryGet(out IPopulation pop) ? $"\nНаселение: {pop.Total} / {pop.HousingCap}" : ""; - info = $"Дом: +{perLevel * rc.level} к пределу жилья (+{perLevel} за уровень).{cap}"; - } - else if (rc != null && rc.resourceType == ResourceType.Gems) - { - info = "Синий кристалл добывается в кристальных разломах на карте мира."; - } - else if (storage != null) - { - var s = config.storage; - info = storage.kind == StorageBuilding.Kind.Warehouse - ? $"Склад: +{s.perWarehouseLevel * storage.level:0} к лимиту дерева, камня, металла и золота." - : $"Амбар: +{s.perGranaryLevel * storage.level:0} к лимиту еды."; - } - else info = ""; + UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); - if (info.Length > 0) UIKit.Text(card, info, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IPopulation population)) + { + Header(card, "Город"); + LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f, + () => $"Население {population.Total} / {population.HousingCap}", UIKit.Theme.Blue); + Live(card, () => $"Жителей {population.Civilians}: рабочих {population.Workers}, свободных {population.Idle} • воинов {population.Soldiers}", + 22, UIKit.Theme.TextDim); + Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim); + } - if (leveled == null || (rc != null && rc.resourceType == ResourceType.Gems)) return; - if (leveled.Level >= config.upgrade.maxLevel) + if (level >= townHall.maxLevel) { UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); return; } - var cost = config.UpgradeCost(leveled.Level); - bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost); - var b = UIKit.Button(card, $"Улучшить до {leveled.Level + 1} ур. ({CostText(cost)})", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => + // Что даст следующий уровень + Header(card, $"Уровень {next}"); + var gains = new List { $"жильё +{p.housingPerTownHallLevel}" }; + int nowReserve = ReserveAt(level), nextReserve = ReserveAt(next); + if (nextReserve != nowReserve) gains.Add($"резерв для найма {nowReserve} → {nextReserve}"); + UIKit.Text(card, string.Join(", ", gains.ToArray()), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + string unlocks = UnlocksAt(next); + if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center); + + var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost()); + UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + bool canPay = townHall.CanUpgrade(); + var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => { - if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; } - leveled.Level = leveled.Level + 1; + if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; } + townHall.Upgrade(); if (CitySync.Instance != null) CitySync.Instance.SyncNow(); - UIKit.Toast($"Улучшено до {leveled.Level} уровня"); + UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня"); Rebuild(); }, -1, 80, 26); b.interactable = canPay; } + // ---------------- Казармы ---------------- + + static void BarracksSection(RectTransform card, Barracks barracks) + { + var gp = GameProgress.Instance; + UIKit.Text(card, $"Казармы обучают войска. Казарм в городе: {Barracks.Count}, мест в очереди: {Barracks.QueueSize}. " + + $"Новые войска открывает уровень самых прокачанных Казарм ({Barracks.MaxLevel} ур.) вместе с уровнем Ратуши.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + if (gp != null) + { + Header(card, "Обучение"); + Live(card, () => + { + var d = gp.Data; + if (d.training.Count == 0) return "Никто не обучается"; + var o = d.training[0]; + string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : ""; + return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}"; + }, 24, UIKit.Theme.Text); + if (GameServices.TryGet(out IPopulation population)) + Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim); + + Header(card, "Войска"); + for (int i = 0; i < GameData.Troops.Count; i++) + { + string lockText = gp.TroopLockText(i); + UIKit.Text(card, lockText.Length == 0 + ? $"{GameData.Troops[i].name} — открыты" + : $"{GameData.Troops[i].name} — нужно: {lockText}", + 24, lockText.Length == 0 ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center); + } + } + + // Что откроет следующий уровень этих Казарм + var opens = new List(); + if (gp != null) + for (int i = 0; i < GameData.Troops.Count; i++) + if (gp.RequiredBarracksLevel(i) == barracks.level + 1 && Barracks.MaxLevel <= barracks.level) opens.Add(GameData.Troops[i].name); + string effect = opens.Count > 0 + ? $"откроются: {string.Join(", ", opens.ToArray())} (если хватает уровня Ратуши)" + : "ближе к новым войскам"; + UpgradeBlock(card, barracks.level, effect, () => barracks.Level = barracks.level + 1); + + UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () => + { + Close(); + if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops"); + }, -1, 80, 26); + } + + // Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения) + static int ReserveAt(int townHallLevel) + { + var table = GameModules.Population != null ? GameModules.Population.Rules.CivilianReserveByTownHallLevel : null; + if (table == null || table.Length == 0) return 0; + return table[Mathf.Clamp(townHallLevel, 1, table.Length) - 1]; + } + + // Здания и войска, которые открываются ровно на этом уровне Ратуши + static string UnlocksAt(int townHallLevel) + { + var names = new List(); + if (BuildManager.Instance != null && BuildManager.Instance.allBuildings != null) + foreach (var b in BuildManager.Instance.allBuildings) + if (b != null && b.requiredTownHallLevel == townHallLevel) names.Add(b.buildingName); + foreach (var t in GameData.Troops) + if (t.requiredTownHall == townHallLevel) names.Add(t.name); + return string.Join(", ", names.ToArray()); + } + + // ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ---------------- + + static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel) + { + var config = EconomyConfig.Load(); + if (level >= config.upgrade.maxLevel) + { + UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); + return; + } + Header(card, $"Улучшение до {level + 1} ур."); + UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + var cost = config.UpgradeCost(level); + UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + + bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost); + var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => + { + if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; } + raiseLevel(); + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + UIKit.Toast($"Улучшено до {level + 1} уровня"); + Rebuild(); + }, -1, 80, 26); + b.interactable = canPay; + } + + // Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть static string CostText(ResourceAmount[] cost) { + GameServices.TryGet(out IEconomy economy); var parts = new string[cost.Length]; - for (int i = 0; i < cost.Length; i++) parts[i] = $"{cost[i].Amount:0} {GameData.ResName(cost[i].Type).ToLower()}"; + for (int i = 0; i < cost.Length; i++) + { + float have = economy != null ? economy.Get(cost[i].Type) : 0f; + bool ok = have >= cost[i].Amount; + string name = GameData.ResName(cost[i].Type).ToLower(); + parts[i] = ok + ? $"{cost[i].Amount:0} {name}" + : $"{cost[i].Amount:0} {name} (есть {have:0})"; + } return string.Join(", ", parts); } - // ---------------- Рабочие по профессиям ---------------- + // ---------------- Склад: полоска и время до заполнения ---------------- - static void WorkersSection(RectTransform card) + static void StorageBar(RectTransform card, IEconomy economy, ResourceType res) + { + string name = GameData.ResName(res); + LiveBar(card, () => + { + float cap = economy.GetCapacity(res); + return float.IsInfinity(cap) || cap <= 0 ? 0f : economy.Get(res) / cap; + }, + () => + { + float cap = economy.GetCapacity(res); + return float.IsInfinity(cap) ? $"{name}: {economy.Get(res):0}" : $"{name}: {economy.Get(res):0} / {cap:0}"; + }, + new Color(0.85f, 0.65f, 0.25f)); + } + + static string FillTimeText(IEconomy economy, ResourceType res, float ratePerHour) + { + float cap = economy.GetCapacity(res), have = economy.Get(res); + if (float.IsInfinity(cap)) return ""; + if (have >= cap) return "Склад полон — добыча пропадает. Улучшите Склад или Амбар."; + if (ratePerHour <= 0) return "Добычи нет — поставьте рабочих"; + float hours = (cap - have) / ratePerHour; + return hours < 1f ? $"Склад заполнится через {Mathf.CeilToInt(hours * 60)} мин" : $"Склад заполнится через {hours:0.#} ч"; + } + + // ---------------- Список рабочих по всем профессиям ---------------- + + static void WorkersList(RectTransform card) { if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) { - UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center); + UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center); return; } for (int j = 0; j < WorkJobs.Count; j++) { var job = (WorkJob)j; - int slots = prod.Slots(job); - int workers = pop.GetWorkers(job); var row = UIKit.Row(card, 72, 8); - - string rate = slots > 0 ? $" +{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч" : ""; - var label = UIKit.Text(row, slots > 0 ? $"{WorkJobs.Name(job)}: {workers} / {slots}{rate}" : $"{WorkJobs.Name(job)}: нет зданий", - 24, slots > 0 ? UIKit.Theme.Text : UIKit.Theme.TextDim); + var label = Live(row, () => + { + int slots = prod.Slots(job); + if (slots <= 0) return $"{WorkJobs.Name(job)}: нет зданий"; + return $"{WorkJobs.Name(job)}: {pop.GetWorkers(job)} / {slots} +{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч"; + }, 24, UIKit.Theme.Text); label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24; + label.alignment = TextAlignmentOptions.Left; UIKit.Size(label, -1, 72, 1); - StepButton(row, "−5", job, -5, slots > 0 && workers > 0); - StepButton(row, "−1", job, -1, slots > 0 && workers > 0); - StepButton(row, "+1", job, +1, slots > 0 && workers < slots && pop.FreeWorkers > 0); - StepButton(row, "+5", job, +5, slots > 0 && workers < slots && pop.FreeWorkers > 0); + StepButton(row, "−5", job, -5); + StepButton(row, "−1", job, -1); + StepButton(row, "+1", job, +1); + StepButton(row, "+5", job, +5); } - UIKit.Text(card, $"Свободно: {pop.FreeWorkers} из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}", - 24, UIKit.Theme.Gold, TextAlignmentOptions.Center); + Live(card, () => $"Свободных жителей: {pop.FreeWorkers} из {pop.Civilians}", 24, UIKit.Theme.Gold); } - static void StepButton(Transform row, string label, WorkJob job, int delta, bool enabled) + // Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить + static void StepButton(Transform row, string label, WorkJob job, int delta) { - var b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => + Button b = null; + b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (GameServices.TryGet(out IPopulation pop)) pop.SetWorkers(job, pop.GetWorkers(job) + delta); - Rebuild(); + Tick(); }, 86, 64, 24); - b.interactable = enabled; + live.Add(() => + { + if (b == null || !GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) return; + int workers = pop.GetWorkers(job), slots = prod.Slots(job); + b.interactable = delta < 0 ? workers > 0 : slots > workers && pop.FreeWorkers > 0; + }); + } + + // ---------------- Мелочи ---------------- + + static void Header(RectTransform card, string text) + { + UIKit.Text(card, text, 28, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold; + } + + // Текст, который обновляется, пока окно открыто + static TextMeshProUGUI Live(Transform parent, Func text, float size, Color color) + { + var t = UIKit.Text(parent, text(), size, color, TextAlignmentOptions.Center); + live.Add(() => { if (t != null) t.text = text(); }); + return t; + } + + static void LiveBar(RectTransform card, Func fraction, Func label, Color color) + { + var bar = UIKit.Bar(card, fraction(), color, 34, label()); + live.Add(() => { if (bar != null) UIKit.SetBar(bar, fraction(), label()); }); + } +} + +// Обновляет открытое окно здания раз в полсекунды (добыча идёт, склад наполняется) +public class BuildingWindowTicker : MonoBehaviour +{ + float next; + + void Update() + { + if (Time.unscaledTime < next) return; + next = Time.unscaledTime + 0.5f; + BuildingWindow.Tick(); } } diff --git a/Assets/Scripts/App/CitySync.cs b/Assets/Scripts/App/CitySync.cs index ab7314c..c0250c4 100644 --- a/Assets/Scripts/App/CitySync.cs +++ b/Assets/Scripts/App/CitySync.cs @@ -117,21 +117,13 @@ public class CitySync : MonoBehaviour var population = GameModules.Population; if (production == null || population == null) return; - // Места для рабочих: где мест стало больше (построили, улучшили) -- ставим туда свободных - var before = new int[WorkJobs.Count]; - for (int j = 0; j < WorkJobs.Count; j++) before[j] = production.Slots((WorkJob)j); + // Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам + // не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем. production.SetBuildings(city.Workplaces); population.SetHouseLevels(city.HouseLevels); population.ClampJobs(); - if (!population.JobsInitialized) - { - population.AutoFillAll(); // первый запуск после этапа 3 или новая игра - } - else - { - for (int j = 0; j < WorkJobs.Count; j++) - if (production.Slots((WorkJob)j) > before[j]) population.AutoFill((WorkJob)j); - } + // Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения + if (!population.JobsInitialized) population.AutoFillAll(); } } diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index 351ec30..9682259 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -40,10 +40,8 @@ public class EconomyConfig : ScriptableObject [Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")] public int maxOfflineHours = 48; - [Tooltip("Гражданский резерв: столько свободных жителей нельзя забрать в войско (решение команды от 19.10: 150 свободных, резерв 30 -> 120)")] - public int civilianReserve = 30; - [Tooltip("Прибавка к резерву за каждый следующий уровень Ратуши. Не утверждено")] - public int civilianReservePerTownHallLevel = 0; + [Tooltip("Гражданский резерв по уровню Ратуши: [0] -- уровень 1, [1] -- уровень 2... Столько свободных жителей нельзя забрать в войско. Регламент 31.10, ТЕСТ: 1-2 -- 10, 3-5 -- 20, 6-10 -- 30")] + public int[] civilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 }; } [Serializable] @@ -65,6 +63,8 @@ public class EconomyConfig : ScriptableObject public int workerSlotsPerLevel = 10; [Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")] public float efficiencyPerLevel = 0.1f; + [Tooltip("Голод: множитель добычи всех ресурсов, кроме еды (0.5 -- вдвое меньше). Еда добывается без штрафа. Регламент 31.10, ТЕСТ")] + [Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f; } [Serializable] @@ -118,6 +118,9 @@ public class EconomyConfig : ScriptableObject public float gold = 5f; [Tooltip("Время подготовки, секунд. Не утверждено")] public int trainSeconds = 60; + [Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " + + "Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")] + public int[] requiredBarracksLevel = { 1, 2, 3, 5 }; } [Serializable] diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index f320dbe..c7e1250 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -69,6 +69,8 @@ public static class GameModules ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped; StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped; StorageBuilding.Tapped += BuildingWindow.OnStorageTapped; + TownHall.Tapped -= BuildingWindow.OnTownHallTapped; + TownHall.Tapped += BuildingWindow.OnTownHallTapped; EventBus.Subscribe(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс EventBus.Subscribe(e => population.Tick(e.UnixNow)); // перемотка (отладка) EventBus.Subscribe(_ => population.Changed()); // воины -- тоже люди @@ -169,8 +171,9 @@ public static class GameModules HousingPerTownHallLevel = p.housingPerTownHallLevel, HousingPerHouseLevel = p.housingPerHouseLevel, MaxOfflineHours = p.maxOfflineHours, - CivilianReserve = p.civilianReserve, - CivilianReservePerTownHallLevel = p.civilianReservePerTownHallLevel, + CivilianReserveByTownHallLevel = p.civilianReserveByTownHallLevel != null && p.civilianReserveByTownHallLevel.Length > 0 + ? (int[])p.civilianReserveByTownHallLevel.Clone() + : new PopulationRules().CivilianReserveByTownHallLevel, PeoplePerSoldier = Mathf.Max(1, config.soldier.people) }; } @@ -184,7 +187,8 @@ public static class GameModules PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour }, SlotsPerBuilding = p.workerSlotsT1, SlotsPerLevel = p.workerSlotsPerLevel, - EfficiencyPerLevel = p.efficiencyPerLevel + EfficiencyPerLevel = p.efficiencyPerLevel, + FamineNonFoodMultiplier = p.famineNonFoodMultiplier }; } diff --git a/Assets/Scripts/Economy/BuildingUI.cs b/Assets/Scripts/Economy/BuildingUI.cs index 6fdf34d..70aa9ca 100644 --- a/Assets/Scripts/Economy/BuildingUI.cs +++ b/Assets/Scripts/Economy/BuildingUI.cs @@ -2,9 +2,8 @@ using UnityEngine; using UnityEngine.UI; using TMPro; -// Полоска над зданием (этап 3): рабочие этой профессии / места в городе. -// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие. -// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта. +// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию). +// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается. public class BuildingUI : MonoBehaviour { [Header("Настройки")] @@ -14,8 +13,16 @@ public class BuildingUI : MonoBehaviour float next; + public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true + void Start() { + if (!ShowLabels) + { + HideLabel(); + enabled = false; + return; + } if (building == null) { Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!"); @@ -25,6 +32,18 @@ public class BuildingUI : MonoBehaviour UpdateUI(); } + // Прячем холст с подписью и полоской. Сам объект здания не трогаем: компонент висит на его корне + void HideLabel() + { + foreach (Component c in new Component[] { stokText, progressBar }) + { + if (c == null) continue; + var canvas = c.GetComponentInParent(true); + GameObject target = canvas != null && canvas.gameObject != gameObject ? canvas.gameObject : c.gameObject; + if (target != gameObject) target.SetActive(false); + } + } + void Update() { if (Time.unscaledTime < next) return; diff --git a/Assets/Scripts/Meta/Build/Barracks.cs b/Assets/Scripts/Meta/Build/Barracks.cs index eb34e61..1589dd5 100644 --- a/Assets/Scripts/Meta/Build/Barracks.cs +++ b/Assets/Scripts/Meta/Build/Barracks.cs @@ -5,13 +5,25 @@ using TMPro; // КАЗАРМЫ -- здание для найма войск. // - Без казарм нанимать войска нельзя. // - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5). -// - Нажатие на казармы открывает панель "Войска" (найм). -// - Над зданием подпись: что сейчас обучается и сколько осталось. +// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ). +// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding). +// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска". +// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true). // Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels). -public class Barracks : MonoBehaviour +public class Barracks : MonoBehaviour, ILeveledBuilding { public static readonly List All = new List(); public static int Count { get { return All.Count; } } + public static bool ShowLabel = false; + + public int level = 1; + public int Level { get => level; set => level = Mathf.Max(1, value); } + + // Уровень самых прокачанных Казарм в городе (0 -- казарм нет) + public static int MaxLevel + { + get { int m = 0; foreach (var b in All) if (b != null && b.level > m) m = b.level; return m; } + } // Размер очереди обучения в зависимости от числа казарм public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } } @@ -24,6 +36,7 @@ public class Barracks : MonoBehaviour void Start() { + if (!ShowLabel) return; // подпись над зданием (обычный 3D-текст, не UI) var go = new GameObject("BarracksLabel"); go.transform.SetParent(transform, false); @@ -44,6 +57,7 @@ public class Barracks : MonoBehaviour void Update() { + if (label == null) return; if (Time.unscaledTime < next) return; next = Time.unscaledTime + 0.5f; Refresh(); @@ -64,10 +78,18 @@ public class Barracks : MonoBehaviour (d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + ""; } + Vector3 downPos; + void OnMouseDown() + { + downPos = Input.mousePosition; + } + + // Тап (а не перетаскивание камеры) -- окно Казарм + void OnMouseUpAsButton() { if (UIBlocker.IsPointerOverUI()) return; - if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return; - if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops"); + if ((Input.mousePosition - downPos).magnitude > 25f) return; + BuildingWindow.Open(gameObject); } } diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 777218d..e9105b9 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -622,7 +622,7 @@ public class GameProgress : MonoBehaviour // Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему static IArmy Army => GameServices.Get(); - // Люди города -- модуль Поселения (часть «Население»). null -- модуль не подключён + // Люди города -- модуль Населения. null -- модуль не подключён static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null; // Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма) @@ -635,15 +635,36 @@ public class GameProgress : MonoBehaviour get { return Army.SoldiersAtHome; } } - public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; } + // Тип войск открыт, если выполнены оба условия: уровень Ратуши (GameData.Troops) и уровень Казарм + // (EconomyConfig, раздел "Солдат", ТЕСТ). Считаются самые прокачанные Казармы. + public bool TroopUnlocked(int index) + { + return TownHallLevel >= GameData.Troops[index].requiredTownHall && Barracks.MaxLevel >= RequiredBarracksLevel(index); + } + + public int RequiredBarracksLevel(int index) + { + var table = EconomyConfig.Load().soldier.requiredBarracksLevel; + return table != null && index >= 0 && index < table.Length ? Mathf.Max(1, table[index]) : 1; + } + + // Чего не хватает, чтобы открыть тип войск: "Ратуша 2 ур., Казармы 3 ур." (пусто -- открыт) + public string TroopLockText(int index) + { + var parts = new List(); + int th = GameData.Troops[index].requiredTownHall, bk = RequiredBarracksLevel(index); + if (TownHallLevel < th) parts.Add("Ратуша " + th + " ур."); + if (Barracks.MaxLevel < bk) parts.Add("Казармы " + bk + " ур."); + return string.Join(", ", parts.ToArray()); + } public void Train(int index, int count) { var def = GameData.Troops[index]; - if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; } if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; } + if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; } if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; } - // Воин -- житель города (модуль Поселения, часть «Население»): берутся свободные жители, резерв не отдаётся + // Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся var population = Population; if (population != null && population.Recruitable < count) { @@ -653,12 +674,12 @@ public class GameProgress : MonoBehaviour return; } if (!Pay(def.cost, count)) return; - if (population != null) population.TryRecruit(count); + bool recruited = population != null && population.TryRecruit(count); long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now; if (start < Now) start = Now; long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks; - Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end }); + Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited }); UIKit.Toast("Обучение начато: " + count + " × " + def.name); MarkChanged(); } @@ -697,8 +718,16 @@ public class GameProgress : MonoBehaviour { var o = Data.training[0]; Data.training.RemoveAt(0); - if (Population != null) Population.RecruitsTrained(o.count); // сначала из Казарм, потом в Армию -- без двойного счёта - Army.AddTroops(o.troopIndex, o.count); + if (o.recruited && Population != null) + { + // Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются + if (!Population.CompleteTraining(o.troopIndex, o.count)) + { + Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах"); + continue; // воинов не прибавилось -- и в статистику не идёт + } + } + else Army.AddTroops(o.troopIndex, o.count); // старый заказ: людей под него не брали Data.troopsTrained += o.count; Data.dailyTroops += o.count; Data.eventTroopRemainder += o.count; diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index cab244c..c830da0 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -24,6 +24,7 @@ public class TrainingOrder public int count; public long startTicks; public long endTicks; + public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали } [Serializable] diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index dd51d46..eb45b40 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -26,7 +26,7 @@ public class TroopsPanel : BasePanel ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get().TroopsInMarches.ToString("N0")); if (GameServices.TryGet(out IPopulation population)) { - // Воины берутся из свободных жителей (модуль Поселения), резерв не нанять + // Воины берутся из свободных жителей (модуль Населения), резерв не нанять ProfilePanel.KV(army, "Обучаются в Казармах", population.InTraining.ToString("N0")); ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") + " (резерв " + population.CivilianReserve.ToString("N0") + ")"); @@ -104,7 +104,7 @@ public class TroopsPanel : BasePanel if (!unlocked) { - UIKit.Text(card, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); + UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); continue; } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 16d9ad0..66d5ee8 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -46,14 +46,14 @@ public interface ILeveledBuilding int Level { get; set; } } -// ---------------- Население (часть модуля Поселения): жители, еда, прирост, найм ---------------- +// ---------------- Население: жители, рабочие, еда, голод, прирост, найм ---------------- public interface IPopulation { int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах) int Civilians { get; } // жители (не воины) int Workers { get; } // жители на работе (все профессии вместе) int Idle { get; } // остальные жители: Civilians - Workers - int Workforce { get; } // сколько жителей могут работать: 40% всех людей города (стартовый город -- 120 из 300) + int Workforce { get; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10) int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers int Soldiers { get; } // воины -- их хранит модуль Армии int HousingCap { get; } // предел населения: выше него прирост не идёт @@ -70,7 +70,11 @@ public interface IPopulation int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет - void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия + void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии) + // Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если + // Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет, + // Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется). + bool CompleteTraining(int troopType, int soldiers); // Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке) void Tick(long unixNow); diff --git a/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs b/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs index 32e1f76..034460c 100644 --- a/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs +++ b/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs @@ -12,13 +12,18 @@ public sealed class ProductionRules public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30) public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено + + // Голод (регламент 31.10, ТЕСТ): добыча всех ресурсов, кроме еды, ×0.5. Еда -- без штрафа. + // Штраф снимается сразу, как только город снова сыт (модуль Населения сообщает IsStarving). + public double FamineNonFoodMultiplier = 0.5; } // МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md // Без Unity: правила те же, что будут на сервере. // // Ресурс производят люди, здание даёт только места и эффективность: -// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий. +// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий, +// а при голоде всё, кроме еды, -- ещё × FamineNonFoodMultiplier (голод считает модуль Населения, IPopulation.IsStarving). // Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет. // Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт. // @@ -80,7 +85,10 @@ public sealed class ProductionService : IProduction, ISaveParticipant int j = (int)job; if (!GameServices.TryGet(out IPopulation population)) return 0; int working = Math.Min(population.GetWorkers(job), slots[j]); - return working * rules.PerWorkerPerHour[j] * efficiency[j]; + double rate = working * rules.PerWorkerPerHour[j] * efficiency[j]; + if (population.IsStarving && WorkJobs.Resource(job) != ResourceType.Food) + rate *= Math.Max(0, Math.Min(1, rules.FamineNonFoodMultiplier)); + return rate; } public void Produce(double hours) diff --git a/Assets/Scripts/Modules/Settlement/Population.meta b/Assets/Scripts/Modules/Population.meta similarity index 100% rename from Assets/Scripts/Modules/Settlement/Population.meta rename to Assets/Scripts/Modules/Population.meta diff --git a/Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef b/Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef similarity index 87% rename from Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef rename to Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef index d2bc05b..867e1b6 100644 --- a/Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef +++ b/Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef @@ -1,5 +1,5 @@ { - "name": "Bogatyri.Settlement.Population", + "name": "Bogatyri.Population", "rootNamespace": "", "references": ["Bogatyri.Core"], "includePlatforms": [], diff --git a/Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef.meta b/Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef.meta similarity index 100% rename from Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef.meta rename to Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef.meta diff --git a/Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs b/Assets/Scripts/Modules/Population/PopulationRules.cs similarity index 71% rename from Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs rename to Assets/Scripts/Modules/Population/PopulationRules.cs index 5bdfbe6..1201b7a 100644 --- a/Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs +++ b/Assets/Scripts/Modules/Population/PopulationRules.cs @@ -1,4 +1,4 @@ -// МОДУЛЬ ПОСЕЛЕНИЯ, часть «Население» (люди города). Документ: Docs/Modules/Population.md +// МОДУЛЬ НАСЕЛЕНИЯ. Документ: Docs/Modules/Population.md // Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules), // в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки). public sealed class PopulationRules @@ -22,11 +22,10 @@ public sealed class PopulationRules public int HousingPerTownHallLevel = 50; public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население") - // Найм воинов (решение команды от 19.10.2026): воины набираются из свободных (не работающих) жителей, - // гражданский резерв не отдаётся. Пример команды: 150 свободных, резерв 30 -> нанять можно не больше 120. - // Резерв задаёт уровень поселения: база + прибавка за уровень Ратуши. Прибавка НЕ утверждена. - public int CivilianReserve = 30; - public int CivilianReservePerTownHallLevel = 0; + // Найм воинов: воины набираются из свободных (не работающих) жителей, гражданский резерв не отдаётся. + // Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1-2 -- 10, 3-5 -- 20, 6-10 -- 30. + // Элемент [i] -- резерв на уровне Ратуши i+1; выше последнего уровня -- последнее число. + public int[] CivilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 }; public int PeoplePerSoldier = 1; // сколько жителей стоит один воин (EconomyConfig, раздел "Солдат") // Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено. diff --git a/Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs.meta b/Assets/Scripts/Modules/Population/PopulationRules.cs.meta similarity index 100% rename from Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs.meta rename to Assets/Scripts/Modules/Population/PopulationRules.cs.meta diff --git a/Assets/Scripts/Modules/Settlement/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs similarity index 82% rename from Assets/Scripts/Modules/Settlement/Population/PopulationService.cs rename to Assets/Scripts/Modules/Population/PopulationService.cs index 28f0f3f..4b81cdc 100644 --- a/Assets/Scripts/Modules/Settlement/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -3,13 +3,21 @@ using System.Collections.Generic; using System.Globalization; using System.Text; -// МОДУЛЬ ПОСЕЛЕНИЯ, часть «Население»: сколько в городе людей, кто где работает, сколько едят и как прирастают. -// Поселение -- это город: здания (Settlement/*.cs) и люди (эта папка, сборка Bogatyri.Settlement.Population). +// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают. // Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере. // // Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час) // и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах. -// Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях. +// Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают +// свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей. +// +// Каждый человек ровно в одной группе (регламент 31.10): +// свободный житель = Civilians - Workers (Idle) +// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных) +// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает) +// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию) +// Total = Civilians + InTraining + Soldiers -- сумма всех групп. +// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок. // Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять. // // Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре, @@ -60,16 +68,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant public int Idle => Math.Max(0, civilians - Workers); - // Сколько людей могут работать: та же доля от всех людей города, что в стартовом городе (120 из 300). - // Считается от всех людей, а не от жителей: найм воина из свободных жителей рабочих не снимает. - public int Workforce - { - get - { - double share = Math.Min(1.0, rules.StartWorkers / (double)Math.Max(1, rules.StartTotal)); - return Math.Min(civilians, (int)Math.Floor(Total * share + Epsilon)); - } - } + // Работать может любой житель: общего ограничения нет (регламент 31.10). + // Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots). + public int Workforce => civilians; public int FreeWorkers => Math.Max(0, Workforce - Workers); @@ -93,7 +94,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant get { int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1; - return Math.Max(0, rules.CivilianReserve + rules.CivilianReservePerTownHallLevel * (level - 1)); + int[] table = rules.CivilianReserveByTownHallLevel; + if (table == null || table.Length == 0) return 0; + return Math.Max(0, table[Math.Min(level, table.Length) - 1]); } } @@ -111,6 +114,27 @@ public sealed class PopulationService : IPopulation, ISaveParticipant return true; } + public bool CompleteTraining(int troopType, int soldiers) + { + if (soldiers <= 0) return false; + int people = soldiers * Math.Max(1, rules.PeoplePerSoldier); + if (people > inTraining) return false; // столько новобранцев нет (повтор или ошибка) + if (!GameServices.TryGet(out IArmy army)) return false; + + // Сначала убираем из Казарм, потом отдаём в Армию: событие Армии увидит уже правильную сумму. + // Армия не приняла -- возвращаем новобранцев, как было. + inTraining -= people; + int before = army.SoldiersAtHome; + army.AddTroops(troopType, soldiers); + if (army.SoldiersAtHome - before != soldiers) + { + inTraining += people; + return false; + } + Changed(); + return true; + } + public void RecruitsTrained(int soldiers) { if (soldiers <= 0) return; @@ -172,7 +196,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant int j = (int)job; if (j < 0 || j >= WorkJobs.Count) return 0; - int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить + int limit = jobs[j] + FreeWorkers; // только свободные жители: не воины, не новобранцы, не уже работающие int slots = SlotsFor(job); if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях int value = Math.Max(0, Math.Min(count, limit)); @@ -293,25 +317,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant // ---------------- Рабочие: расстановка ---------------- - // Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено. - public int AutoFill(WorkJob job) - { - int slots = SlotsFor(job); - int room = slots < 0 ? FreeWorkers : Math.Min(FreeWorkers, slots - jobs[(int)job]); - if (room <= 0) return 0; - SetWorkers(job, jobs[(int)job] + room); - return room; - } - - // Расставить свободных по всем профессиям поровну (старое сохранение, первый запуск после этапа 3) + // Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10). + // Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false). + // Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки + // и улучшения остаются пустыми, пока игрок сам не поставит рабочих. public void AutoFillAll() { bool changed = false; bool progress = true; - while (FreeWorkers > 0 && progress) + int cap = Math.Max(Workers, rules.StartWorkers); + while (FreeWorkers > 0 && Workers < cap && progress) { progress = false; - for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0; j++) + for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0 && Workers < cap; j++) { int slots = SlotsFor((WorkJob)j); if (slots >= 0 && jobs[j] >= slots) continue; @@ -477,6 +495,16 @@ public sealed class PopulationService : IPopulation, ISaveParticipant case "kit": startingKitGiven = value == "1"; break; } } + + // Рабочих не может быть больше, чем жителей (испорченное или очень старое сохранение). + // Места в зданиях здесь не проверяем: секция Экономики может загрузиться позже -- это делает ClampJobs. + int over = Workers - civilians; + for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--) + { + int take = Math.Min(over, jobs[j]); + jobs[j] -= take; + over -= take; + } } // Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей. diff --git a/Assets/Scripts/Modules/Settlement/Population/PopulationService.cs.meta b/Assets/Scripts/Modules/Population/PopulationService.cs.meta similarity index 100% rename from Assets/Scripts/Modules/Settlement/Population/PopulationService.cs.meta rename to Assets/Scripts/Modules/Population/PopulationService.cs.meta diff --git a/Assets/Scripts/Modules/Settlement/TownHall.cs b/Assets/Scripts/Modules/Settlement/TownHall.cs index 5fa101b..74105a1 100644 --- a/Assets/Scripts/Modules/Settlement/TownHall.cs +++ b/Assets/Scripts/Modules/Settlement/TownHall.cs @@ -1,13 +1,18 @@ +using System; using UnityEngine; // МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md // Вешается на объект Ратуши, который уже стоит на карте. // Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager. +// Тап по Ратуше открывает окно Ратуши (слой приложения подписан на Tapped), улучшение -- кнопкой в окне. public class TownHall : MonoBehaviour { public static TownHall Instance { get; private set; } + // Тап по Ратуше: интерфейс откроет её окно (модуль про интерфейс не знает) + public static event Action Tapped; + [Header("Уровень")] public int currentLevel = 1; public int maxLevel = 10; @@ -64,10 +69,18 @@ public class TownHall : MonoBehaviour EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel }); Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!"); } + Vector3 downPos; + void OnMouseDown() { - if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней - Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение..."); - Upgrade(); + downPos = Input.mousePosition; + } + + // Отпустил палец на Ратуше, почти не сдвинув (а не тащил камеру) -- окно Ратуши. Раньше тап сразу улучшал + void OnMouseUpAsButton() + { + if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на Ратушу под ней + if ((Input.mousePosition - downPos).magnitude > 25f) return; + Tapped?.Invoke(this); } } diff --git a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef index de56b9c..08a585f 100644 --- a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef +++ b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef @@ -6,7 +6,7 @@ "UnityEditor.TestRunner", "Bogatyri.Core", "Bogatyri.Army", - "Bogatyri.Settlement.Population", + "Bogatyri.Population", "Bogatyri.Economy.Rules", "Bogatyri.Battle", "Bogatyri.World.Simulation", diff --git a/Assets/Scripts/Modules/Tests/PopulationTests.cs b/Assets/Scripts/Modules/Tests/PopulationTests.cs index 1c1c655..3c40f6b 100644 --- a/Assets/Scripts/Modules/Tests/PopulationTests.cs +++ b/Assets/Scripts/Modules/Tests/PopulationTests.cs @@ -69,9 +69,9 @@ public class PopulationTests Assert.AreEqual(300, p.Total); Assert.AreEqual(270, p.Civilians); - Assert.AreEqual(120, p.Workforce, "могут работать 120"); + Assert.AreEqual(270, p.Workforce, "работать может любой житель -- общего ограничения нет (регламент 31.10)"); Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)"); - Assert.AreEqual(120, p.FreeWorkers); + Assert.AreEqual(270, p.FreeWorkers); Assert.AreEqual(30, p.Soldiers); Assert.AreEqual(33f, p.FoodPerHour, 0.001f); @@ -123,6 +123,7 @@ public class PopulationTests [Test] public void Recruit_150Free_Reserve30_Max120() { + settlement.TownHallLevel = 6; // резерв 30 -- с 6 уровня Ратуши var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 120); // 120 рабочих -- свободных 150 Assert.AreEqual(150, p.Idle); @@ -149,15 +150,163 @@ public class PopulationTests Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды"); } + // ---------------- Группы людей не пересекаются (регламент 31.10) ---------------- + + static void AssertGroupsAddUp(PopulationService p, string when) + { + int free = p.Idle, workers = p.Workers, recruits = p.InTraining, soldiers = p.Soldiers; + Assert.AreEqual(p.Total, free + workers + recruits + soldiers, "свободные + рабочие + новобранцы + воины = все люди: " + when); + Assert.AreEqual(p.Civilians, free + workers, "жители = свободные + рабочие: " + when); + Assert.GreaterOrEqual(free, 0, when); + } + + [Test] + public void Groups_NeverOverlap_SumEqualsTotal() + { + var p = StartCity(); + AssertGroupsAddUp(p, "старт"); + + p.SetWorkers(WorkJob.Farmer, 100); + AssertGroupsAddUp(p, "поставили 100 рабочих"); + + Assert.IsTrue(p.TryRecruit(150)); // берутся только свободные: 170 - резерв 10 = 160 доступно + Assert.AreEqual(100, p.Workers, "рабочих в новобранцы не взяли"); + AssertGroupsAddUp(p, "наняли 150"); + + p.RecruitsTrained(150); + army.AddTroops(0, 150); + AssertGroupsAddUp(p, "новобранцы стали воинами"); + Assert.AreEqual(300, p.Total, "людей не прибавилось и не убавилось"); + } + + [Test] + public void Workers_CannotExceedFreeCivilians() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 100); + p.TryRecruit(150); // жителей осталось 120: 100 рабочих + 20 свободных + + Assert.AreEqual(20, p.SetWorkers(WorkJob.Woodcutter, 50), "поставить можно только 20 свободных -- не воинов и не новобранцев"); + Assert.AreEqual(0, p.FreeWorkers); + Assert.AreEqual(0, p.SetWorkers(WorkJob.Stonecutter, 10), "свободных нет -- никого"); + AssertGroupsAddUp(p, "все жители на работе"); + } + + [Test] + public void Restore_WorkersNeverMoreThanCivilians() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 120); + string save = p.Capture().Replace("civilians=270", "civilians=50"); // испорченное сохранение + var copy = new PopulationService(new PopulationRules()); + copy.Restore(save); + Assert.AreEqual(50, copy.Workers, "рабочих не больше, чем жителей"); + Assert.AreEqual(0, copy.Idle); + } + + // ---------------- Интеграция: новобранцы переходят из Населения в Армию (настоящий ArmyService) ---------------- + + [Test] + public void Training_RecruitsMoveToArmy_NoOneLostOrDuplicated() + { + var p = StartCity(); + int infantryBefore = army.GetTroops(0); + Assert.IsTrue(p.TryRecruit(20)); + AssertGroupsAddUp(p, "новобранцы в Казармах"); + + Assert.IsTrue(p.CompleteTraining(0, 20)); + Assert.AreEqual(0, p.InTraining); + Assert.AreEqual(infantryBefore + 20, army.GetTroops(0), "в Армии +20 пехоты"); + Assert.AreEqual(300, p.Total, "людей столько же"); + AssertGroupsAddUp(p, "обучение закончено"); + } + + [Test] + public void Training_ArmyRejects_RecruitsStayInBarracks() + { + var p = StartCity(); + p.TryRecruit(20); + int soldiersBefore = p.Soldiers; + + Assert.IsFalse(p.CompleteTraining(99, 20), "такого типа войск нет -- Армия не приняла"); + Assert.AreEqual(20, p.InTraining, "новобранцы не пропали"); + Assert.AreEqual(soldiersBefore, p.Soldiers, "и воинов из ничего не появилось"); + Assert.AreEqual(300, p.Total); + AssertGroupsAddUp(p, "Армия не приняла"); + } + + [Test] + public void Training_RepeatedCompletion_DoesNotDuplicate() + { + var p = StartCity(); + p.TryRecruit(20); + Assert.IsTrue(p.CompleteTraining(0, 20)); + Assert.IsFalse(p.CompleteTraining(0, 20), "второй раз новобранцев уже нет"); + Assert.AreEqual(50, p.Soldiers, "30 стартовых + 20, а не 70"); + Assert.AreEqual(300, p.Total); + } + + [Test] + public void Training_NoArmyModule_NothingChanges() + { + var p = StartCity(); + p.TryRecruit(20); + GameServices.Unregister(army); + Assert.IsFalse(p.CompleteTraining(0, 20)); + Assert.AreEqual(20, p.InTraining, "без Армии передавать некуда -- новобранцы ждут"); + } + + [Test] + public void Training_PartOfRecruits_OthersKeepTraining() + { + var p = StartCity(); + p.TryRecruit(20); + Assert.IsTrue(p.CompleteTraining(1, 8)); // 8 лучников готовы + Assert.AreEqual(12, p.InTraining); + Assert.AreEqual(8, army.GetTroops(1)); + Assert.AreEqual(300, p.Total); + AssertGroupsAddUp(p, "часть обучена"); + } + + [Test] + public void Reserve_DependsOnTownHallLevel_10_20_30() + { + var p = StartCity(); + int[] expected = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30, 30 }; // 11 уровень -- как последний + for (int level = 1; level <= expected.Length; level++) + { + settlement.TownHallLevel = level; + Assert.AreEqual(expected[level - 1], p.CivilianReserve, "Ратуша " + level); + } + } + + [Test] + public void Recruit_StartCity_Reserve10() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 120); + Assert.AreEqual(10, p.CivilianReserve, "Ратуша 1 -- резерв 10"); + Assert.AreEqual(140, p.Recruitable, "150 свободных - 10"); + } + [Test] public void Recruit_TakesOnlyFree_WorkersStayAtWork() { var p = StartCity(); p.SetWorkers(WorkJob.Farmer, 120); - p.TryRecruit(120); + p.TryRecruit(140); Assert.AreEqual(120, p.Workers, "рабочие остались на работе"); - Assert.AreEqual(120, p.Workforce); - Assert.AreEqual(30, p.Idle, "остался резерв"); + Assert.AreEqual(10, p.Idle, "остался резерв"); + Assert.AreEqual(0, p.Recruitable); + } + + [Test] + public void AutoFillAll_NewGame_Places120Workers_RestStayFree() + { + var p = StartCity(); + p.AutoFillAll(); // без модуля добычи мест не меньше -- ограничивает только старт: 120 рабочих + Assert.AreEqual(120, p.Workers); + Assert.AreEqual(150, p.Idle); } [Test] diff --git a/Assets/Scripts/Modules/Tests/ProductionTests.cs b/Assets/Scripts/Modules/Tests/ProductionTests.cs index 30220a2..ceaac09 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -59,7 +59,7 @@ public class ProductionTests Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f); Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f); Assert.AreEqual(100, population.Workers); - Assert.AreEqual(20, population.FreeWorkers); + Assert.AreEqual(170, population.FreeWorkers, "работать может любой из 270 жителей"); } [Test] @@ -93,9 +93,10 @@ public class ProductionTests production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить"); - production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Farmer, 1), - (WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); - Assert.AreEqual(90, population.SetWorkers(WorkJob.Farmer, 120), "свободных осталось 90 из 120"); + var farms = new List> { new KeyValuePair(WorkJob.Woodcutter, 1) }; + for (int i = 0; i < 9; i++) farms.Add(new KeyValuePair(WorkJob.Farmer, 1)); // 270 мест + production.SetBuildings(farms); + Assert.AreEqual(240, population.SetWorkers(WorkJob.Farmer, 270), "свободных жителей осталось 240 из 270"); Assert.AreEqual(0, population.FreeWorkers); } @@ -137,18 +138,69 @@ public class ProductionTests Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter)); Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer)); Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter)); - Assert.AreEqual(30, population.FreeWorkers); + Assert.AreEqual(180, population.FreeWorkers); Assert.IsTrue(population.JobsInitialized); } [Test] - public void AutoFill_NewBuilding_TakesFreeWorkers() + public void AutoFillAll_StartCity_NoMoreThan120Workers() + { + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Stonecutter, 1), + (WorkJob.MinerIron, 1), (WorkJob.MinerGold, 1))); // 150 мест + population.AutoFillAll(); + Assert.AreEqual(120, population.Workers, "стартовый город: 120 рабочих"); + Assert.AreEqual(24, population.GetWorkers(WorkJob.Woodcutter), "поровну по профессиям"); + } + + // ---------------- Голод (регламент 31.10, ТЕСТ): добыча кроме еды ×0.5 ---------------- + + [Test] + public void Famine_HalvesNonFoodProduction_FoodUnaffected_PenaltyEndsWhenFed() + { + economy.Set(ResourceType.Food, 0); + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Farmer, 5); // 10 еды в час -- город ест 33, голод + population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час + + population.Tick(T0 + Hour); + Assert.IsTrue(population.IsStarving); + Assert.AreEqual(30f, production.RatePerHour(ResourceType.Wood), 0.001f, "дерево вдвое меньше"); + Assert.AreEqual(10f, production.RatePerHour(ResourceType.Food), 0.001f, "еда без штрафа"); + + economy.Set(ResourceType.Food, 1000); // привезли еду + population.Tick(T0 + 2 * Hour); + Assert.IsFalse(population.IsStarving); + Assert.AreEqual(60f, production.RatePerHour(ResourceType.Wood), 0.001f, "сыты -- штраф снят"); + } + + [Test] + public void NewBuilding_SlotsStayEmpty_UntilPlayerAssigns() { production.SetBuildings(Buildings((WorkJob.Farmer, 1))); - Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer)); - production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); - Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer), "вторая ферма -- ещё 30"); - Assert.AreEqual(60, population.GetWorkers(WorkJob.Farmer)); + population.AutoFillAll(); // стартовая расстановка -- один раз + Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer)); + + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); // построили вторую ферму + population.ClampJobs(); + Assert.AreEqual(60, production.Slots(WorkJob.Farmer)); + Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer), "новые места пустые -- сами не заполняются"); + + Assert.AreEqual(60, population.SetWorkers(WorkJob.Farmer, 60), "игрок ставит сам"); + } + + [Test] + public void StartPlacement_HappensOnlyOnce() + { + production.SetBuildings(Buildings((WorkJob.Farmer, 1))); + population.AutoFillAll(); + Assert.IsTrue(population.JobsInitialized); + population.SetWorkers(WorkJob.Farmer, 0); // игрок снял всех + + // Сохранение и загрузка: расстановка уже была, повторно не нужна + var copy = new PopulationService(new PopulationRules()); + copy.Restore(population.Capture()); + Assert.IsTrue(copy.JobsInitialized, "второй раз CitySync расстановку не вызовет"); + Assert.AreEqual(0, copy.GetWorkers(WorkJob.Farmer)); } [Test] @@ -159,7 +211,7 @@ public class ProductionTests production.SetBuildings(Buildings((WorkJob.Farmer, 1))); population.ClampJobs(); Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer)); - Assert.AreEqual(90, population.FreeWorkers); + Assert.AreEqual(240, population.FreeWorkers); } [Test] diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index ad43886..b207387 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -31,7 +31,7 @@ | `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. | | `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. | | `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. | -| `IPopulation` | Поселение, часть «Население» (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. | +| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. | | `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. | | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. | | `IWorldMap` | Мир (`MarchManager`) | Для Разведки: границы карты, где поселение, все армии в момент времени (`ArmyOnMap`). | @@ -61,11 +61,13 @@ | Дата | Кто | Что | | --- | --- | --- | | 31.10.2026 | Claude | Разведка М1: контракты `IWorldMap`, `IIntelligence` (`ArmyOnMap`, `MapVisibility`, `KnownContact`), события `WorldAdvancingEvent`, `ArmySpottedEvent`, `ArmyIdentifiedEvent`, `ArmyLostContactEvent`. | +| 31.10.2026 | Claude | `IPopulation.CompleteTraining(type, n)` — передача новобранцев в Армию одним действием. | +| 31.10.2026 | Claude | `IPopulation.Workforce` — все жители (ограничения «40% всех людей» больше нет), `CivilianReserve` — по уровню Ратуши. Население — самостоятельный модуль. | | 31.10.2026 | Claude | `MarchReturnedEvent.IsGather` — поход на ресурсную точку. | | 31.10.2026 | Claude | `IArmy.MarchSlots`, `IArmy.ActiveMarches` — слоты похода. | | 30.10.2026 | Claude | События похода: `TimeMs` (когда случилось по часам игры) у `MarchStartedEvent`, `MarchBattleResolvedEvent`, `MarchReturnedEvent`; `BattleId` у `MarchBattleResolvedEvent`. | | 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). | -| 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. Население — часть Поселения. | +| 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. | | 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. | | 28.10.2026 | Claude | Бой: `BattleRequest.Seed`; `BattleOutcome` — номер боя, версия правил, удача сторон, состав до боя, выжившие, погибшие и раненые по типам у обеих сторон; `IBattleSystem.Forecast`, `BattleForecast`, `BattleChance`. | | 28.10.2026 | Claude | `IArmy.ReturnFromMarch` и `IArmy.ReturnUnits` возвращают `bool`: зачисление только по известному походу и один раз. `TakeForMarch` отклоняет повторный номер похода. | diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 9533283..5a3a792 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -43,13 +43,15 @@ ## Добыча через рабочих (этап 3) - **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность. +- **Голод** (регламент 31.10, ТЕСТ): пока город голодает (`IPopulation.IsStarving`), добыча всего, кроме еды, ×0.5 (`EconomyConfig`, `famineNonFoodMultiplier`). Еда добывается без штрафа, штраф снимается сразу, как только город снова сыт. - **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник). - **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено. - **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен. - **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья. - **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6). - **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн. -- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам. +- **Новые места сами не заполняются** (регламент 31.10): построили или улучшили здание — места пустые, пока игрок не поставит рабочих в окне здания. Автоматически — только стартовая расстановка один раз (новая игра или первая загрузка старого сохранения): до 120 рабочих поровну по местам. +- **Голод в офлайне** считается по тем же шагам, что и в игре: Население досчитывает время кусками по 10 минут, и в каждом куске добыча берёт голод на этот момент. - **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час. ## События @@ -107,6 +109,8 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Новые места после стройки и улучшения больше не заполняются сами (`App/CitySync`), только стартовая расстановка один раз. | +| 31.10.2026 | Claude | Голод: добыча всего, кроме еды, ×0.5 (`ProductionRules.FamineNonFoodMultiplier`, ТЕСТ). Тест `Famine_HalvesNonFoodProduction_…`. | | 27.10.2026 | Claude | Этапы 1 и 3: лимиты склада (`GetCapacity`, Склад, Амбар), «запас / лимит» и прирост на панели; добыча через рабочих (`Bogatyri.Economy.Rules`, `IProduction`), кликер убран, окно здания и рабочих, улучшение зданий. Исправлены лишние `ResourceUIText` «Люди» на ячейках панели. | | 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. | | 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 1b6733b..d35559b 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -1,21 +1,22 @@ -# Поселение: население +# Население -Люди города: сколько их, кто работает, сколько едят, как прирастают и кого можно нанять в войско. Это часть модуля [Поселения](Settlement.md): Поселение — это город, его здания и люди. Этап 2 плана разработки. +Люди города: жители, рабочие, прирост, еда, голод, найм в войско и демографические ограничения. Самостоятельный модуль (регламент 28.10 и 31.10.2026). С Поселением (здания, Ратуша) связан только через контракт ядра `ISettlement`. Этап 2 плана разработки. -- **Ответственный:** Вальдемар. -- **Сборка:** `Bogatyri.Settlement.Population` (без Unity). Отдельная сборка внутри Поселения — как правила добычи внутри Экономики: правила людей без Unity переедут на сервер. -- **Папка:** `Assets/Scripts/Modules/Settlement/Population`. +- **Ответственный:** Валдемар. +- **Сборка:** `Bogatyri.Population` (без Unity). +- **Папка:** `Assets/Scripts/Modules/Population`. - **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`. ## Как устроено - **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах. -- **Жители:** способные работать (`Workforce` — 40% всех людей города, в стартовом городе 120 из 300) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`). -- **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся: 150 свободных, резерв 30 — нанять можно не больше 120 (`Recruitable`). Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв задаёт уровень поселения: `CivilianReserve` + прибавка за уровень Ратуши (прибавка не утверждена, сейчас 0). -- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить. +- **Жители:** работать может любой житель — общего ограничения нет (регламент 31.10). Рабочих ограничивают свободные жители и рабочие места в зданиях. Рабочие стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота), остальные свободны. Стартовый город: 120 рабочих из 270 жителей — при первой расстановке (`AutoFillAll`) больше `StartWorkers` (120) не ставится. +- **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся (`Recruitable` = свободные − резерв). Пример команды: 150 свободных, резерв 30 — нанять можно не больше 120. Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1–2 — 10, 3–5 — 20, 6–10 — 30 (`EconomyConfig`, `civilianReserveByTownHallLevel`). +- **Группы людей не пересекаются** (регламент 31.10). Каждый человек ровно в одной группе: свободный житель (`Idle`), рабочий (`Workers`), новобранец (`InTraining`), воин (`Soldiers`, хранит Армия). `Total` — их сумма. На работу ставятся только свободные жители, в войско берутся только свободные жители сверх резерва. +- **Рабочих ставит игрок** в окне здания (`SetWorkers`): не больше, чем мест в зданиях (`IProduction.Slots`) и свободных жителей. Автоматически места заполняются только один раз — стартовая расстановка (`AutoFillAll`, до 120 рабочих) в новой игре или при первой загрузке старого сохранения. Новые места после постройки или улучшения остаются пустыми, пока игрок не поставит рабочих. Мест стало меньше — лишних рабочих снимают (`ClampJobs`). - **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час. - **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья. -- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Бой ослабляет голодное войско (модуль Боя, −50%). +- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Добыча всего, кроме еды, ×0.5 (модуль Экономики, регламент 31.10, ТЕСТ), еда добывается без штрафа. Бой ослабляет голодное войско (модуль Боя, −50%). Штраф снимается сразу, как только город снова сыт. Смерти от голода нет. - **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру. - **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены. - **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали. @@ -38,6 +39,7 @@ | `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. | | `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. | | `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. | +| `CompleteTraining(type, n)` | Передать n новобранцев в Армию одним действием: люди уходят из Населения, только если Армия их приняла. Иначе ничего не меняется — никто не теряется и не дублируется. Так заканчивают обучение Казармы (`GameProgress`). | | `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. | | `Tick(unixNow)` | Досчитать население до момента `unixNow`. | @@ -91,7 +93,7 @@ - сохранение; - стартовый набор — один раз; - сброс; -- найм: 150 свободных, резерв 30 → не больше 120; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус; +- найм: 150 свободных, резерв 30 (Ратуша 6) → не больше 120; стартовый город — резерв 10; резерв по уровням Ратуши 10/20/30; стартовая расстановка — 120 рабочих; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус; - история голода: бой офлайн видит голод на свой момент, история сохраняется, сбрасывается и забывается через 48 часов. ## Решения и план @@ -103,12 +105,16 @@ - п. 7, история голода -- сделано (`WasStarvingAt`). Бою нужно перейти с `IsStarving` на `WasStarvingAt(время боя)`. Походы идут по реальному времени, Население -- по `GameClock`: после перемотки в DEV-панели они расходятся. - п. 8, норма еды на солдата -- сейчас 0.2 еды в час реального времени, игрового календаря нет. Предложение: не вводить; если нужен «игровой месяц» -- 1 месяц = 1 сутки, норма ≈ 5 еды. Решает Дима. - **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026) — сделано 28.10.2026. Цену воина в ресурсах (`EconomyConfig`, раздел «Солдат»: 15 еды, 5 металла, 5 золота) Казармы пока не используют — у них своя цена в `GameData.Troops`. -- **Схема модулей (28.10.2026):** население — часть Поселения, как на схеме Димы из 8 модулей. +- **Схема модулей:** Население — самостоятельный модуль (регламент 28.10 и 31.10.2026). Перенос внутрь Поселения от 28.10 был ошибкой и откатан 31.10. +- **Открыто (регламент 31.10, ждёт решения):** нормы еды для четырёх групп (гражданские и рабочие, новобранцы, солдаты, раненые); переход экономики на игровое время (1 игровой час = 5 реальных минут) и единицы офлайна (48 ч) и истории голода; кто считает предел жилья и рабочие места (по регламенту — Поселение); недоступен ли резерв и для работы; перенос очереди найма из `GameProgress` в Армию (с Сашей). ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. | +| 31.10.2026 | Claude | Автозаполнение новых мест убрано (`AutoFill` удалён, `App/CitySync` больше не ставит рабочих после стройки и улучшения), осталась только стартовая расстановка один раз. При загрузке рабочих не больше, чем жителей. Тесты: группы людей не пересекаются и в сумме дают всех, рабочих не больше свободных, новые места пустые, стартовая расстановка один раз. | +| 31.10.2026 | Claude | По регламенту 31.10: Население снова самостоятельный модуль (`Modules/Population`, сборка `Bogatyri.Population`); убрано ограничение «40% всех людей» — работать может любой житель, первая расстановка — 120 рабочих; резерв по уровню Ратуши 10/20/30 (ТЕСТ, `civilianReserveByTownHallLevel` вместо `civilianReserve`); при голоде добыча кроме еды ×0.5 (ТЕСТ). Тесты. | | 28.10.2026 | Claude | Население стало частью Поселения: папка `Modules/Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом (`CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`), строка `training=`, `Workforce` — 40% всех людей. Тесты. | | 28.10.2026 | Claude | История голода: `IPopulation.WasStarvingAt`, строка `famine=` в сохранении, тесты (вопрос Саши п. 7). | | 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 156e935..71f32f1 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -1,6 +1,6 @@ # Модульная система «Богатырей» -Игра собрана из восьми модулей вокруг общего **Игрового ядра** — как на схеме Димы. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется. +Игра собрана из десяти модулей вокруг общего **Игрового ядра** (регламент 31.10.2026). Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется. ``` ┌─────────────────────────────────────────┐ @@ -11,28 +11,32 @@ ┌──────────────────────┐ ┌──────────────────────┐ │ ЭКОНОМИКА │ │ ПОСЕЛЕНИЕ │ -│ ресурсы, склады, │ │ здания: Ратуша, │ -│ лимиты, добыча │ │ стройка; │ -│ рабочими │ │ люди: жители, еда, │ -│ │ │ голод, прирост, найм │ +│ ресурсы, склады, │ │ здания, Ратуша, │ +│ лимиты, добыча │ │ участки, стройка │ +│ рабочими │ │ │ └──────────────────────┘ └──────────────────────┘ ┌──────────────────────┐ ┌──────────────────────┐ -│ АРМИЯ │ │ ГЛОБАЛЬНЫЙ МИР │ -│ воины по типам: дома,│ │ карта, походы, замок,│ -│ в гарнизоне, │ │ соседи, лагеря │ -│ в походе │ │ разбойников │ +│ НАСЕЛЕНИЕ │ │ АРМИЯ │ +│ люди, рабочие, еда, │ │ воины по типам: дома,│ +│ голод, прирост, │ │ в гарнизоне, │ +│ жильё │ │ в походе │ └──────────────────────┘ └──────────────────────┘ ┌──────────────────────┐ ┌──────────────────────┐ -│ БОЕВАЯ СИСТЕМА │ │ КРУГ │ -│ сила, контры, стены, │ │ объединение игроков, │ -│ удача ±5%, голод, │ │ роли │ -│ прогноз │ │ │ +│ ГЛОБАЛЬНЫЙ МИР │ │ БОЕВАЯ СИСТЕМА │ +│ карта, походы, замок,│ │ сила, контры, стены, │ +│ соседи, лагеря │ │ удача ±5%, голод, │ +│ разбойников │ │ прогноз │ └──────────────────────┘ └──────────────────────┘ ┌──────────────────────┐ ┌──────────────────────┐ -│ ЧАТ │ │ СОХРАНЕНИЯ │ -│ общий мир и чат │ │ save.json, у каждого │ -│ Круга │ │ модуля своя секция │ +│ КРУГ │ │ ЧАТ │ +│ объединение игроков, │ │ общий мир и чат │ +│ роли │ │ Круга │ └──────────────────────┘ └──────────────────────┘ +┌──────────────────────┐ +│ СОХРАНЕНИЯ │ +│ save.json, у каждого │ +│ модуля своя секция │ +└──────────────────────┘ App/GameModules — точка сборки: регистрирует модули и соединяет их события со старыми системами (GameProgress, Казармы, панели UI) @@ -40,7 +44,7 @@ App/GameModules — точка сборки: регистрирует модул **Не путать:** -- **Поселение** — это **город**: его **здания** (что построено и какого уровня, `Modules/Settlement`) и его **люди** (сколько их, кто работает, сколько едят, кого нанять в войско, `Modules/Settlement/Population`). Люди — отдельная сборка без Unity внутри Поселения, как правила добычи внутри Экономики. Связь: уровень Ратуши задаёт предел жилья. +- **Поселение** — это **здания**: что построено и какого уровня. **Население** — это **люди**: сколько их, кто работает, сколько едят. Связь: уровень Ратуши (Поселение) задаёт предел жилья (Население). - **Армия** хранит, где стоит каждый воин. **Боевая система** только считает бой: кто победил и кто погиб. - **Глобальный мир** решает, куда и когда идёт войско; бой он просит у Боевой системы. - **Производство** (`Bogatyri.Economy.Rules`) — не отдельный модуль, а часть Экономики без Unity: добыча через рабочих. @@ -49,15 +53,16 @@ App/GameModules — точка сборки: регистрирует модул | Кто | Модули | | --- | --- | -| Валдемар (экономика) | Экономика, люди Поселения (`Settlement/Population`) | +| Валдемар (экономика) | Экономика, Население | | Саша (война и карта) | Армия, Боевая система, Глобальный мир, Круг, Чат, Разведка | -| Общее | Ядро, здания Поселения, Сохранения | +| Общее | Ядро, Поселение, Сохранения | | Модуль | Сборка | Папка | Документ | | --- | --- | --- | --- | | Игровое ядро | `Bogatyri.Core` (без Unity), `Bogatyri.Core.Unity` | `Assets/Scripts/Modules/Core`, `Core.Unity` | [Core.md](Core.md) | | Экономика | `Bogatyri.Economy`, `Bogatyri.Economy.Rules` (без Unity) | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) | -| Поселение | `Bogatyri.Settlement` (здания), `Bogatyri.Settlement.Population` (люди, без Unity) | `Assets/Scripts/Modules/Settlement`, люди — `Settlement/Population` | [Settlement.md](Settlement.md), [Population.md](Population.md) | +| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) | +| Население | `Bogatyri.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) | | Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) | | Глобальный мир | `Bogatyri.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) | | Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) | @@ -91,15 +96,17 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): - [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет); -- [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120 (`PopulationTests`); +- [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; резерв по уровню Ратуши 10/20/30 (`PopulationTests`); - [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`); -- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%), история голода на момент боя — `PopulationTests`; остаются дебаффы производства и правило снятия — экономика; +- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%), история голода на момент боя — `PopulationTests`; добыча кроме еды при голоде ×0.5 и снятие штрафа сразу после еды — `ProductionTests` (31.10); - [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Окна зданий: подписи над зданиями убраны, у каждого здания (добывающие, Дом, Склад, Амбар, Ратуша, Кристалл) своё окно по тапу — состояние, добыча и склад, улучшение и его цена. Казармы тоже получили окно и уровни (открывают новые войска вместе с Ратушей; уровни 1/2/3/5 — ТЕСТ). Ратуша улучшается кнопкой в окне, а не тапом. Обновлён Settlement. | +| 31.10.2026 | Claude | По регламенту 31.10: откат — Население снова самостоятельный модуль (`Modules/Population`, `Bogatyri.Population`), схема и «Кто отвечает» как у Саши. Работать может любой житель (без «40%»), резерв по уровню Ратуши 10/20/30, голод режет добычу кроме еды вдвое. Обновлены Population, Settlement, Economy, Core. | | 31.10.2026 | Claude | Модуль Разведки, этап М1 ([Intelligence.md](Intelligence.md)): туман, обзор, чужие армии, последний контакт. Патрули-заглушки соседей для проверки. Обновлены World, Core, Save. | | 31.10.2026 | Claude | Панель армий на карте мира (регламент 31.10). Обновлён World. | | 31.10.2026 | Claude | Ресурсные точки на карте мира по регламенту 31.10 (наша часть по решению №46: состояние точки и сбор; зачисление — Экономика через `IEconomy`). Обновлены World, Save, Core. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index a44f2f9..1d43cf1 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -1,12 +1,10 @@ # Поселение -Город: его здания и люди. +Здания и развитие города: Ратуша, участки, свободная стройка на сетке, постройка зданий, восстановление города из сохранения. Люди города — отдельный модуль [Население](Population.md), связь только через контракты ядра. -- **Здания** (этот документ): Ратуша, свободная стройка на сетке, постройка зданий, восстановление города из сохранения. -- **Люди** — жители, рабочие, еда, голод, прирост, жильё, найм воинов: [Поселение: население](Population.md). - -- **Сборки:** `Bogatyri.Settlement` (здания, Unity), `Bogatyri.Settlement.Population` (люди, без Unity). -- **Папка:** `Assets/Scripts/Modules/Settlement`, люди — `Settlement/Population`. +- **Ответственность:** совместная. +- **Сборка:** `Bogatyri.Settlement`. +- **Папка:** `Assets/Scripts/Modules/Settlement`. - **Зависит от:** ядра. Ресурсы на стройку берёт через `IEconomy`. ## Стройка на сетке @@ -84,6 +82,9 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. | +| 31.10.2026 | Claude | Казармы стали уровневым зданием (`ILeveledBuilding`, уровень сохраняется как у остальных). Тап открывает окно Казарм: очередь обучения, какие войска открыты и что нужно для остальных, улучшение, кнопка «Нанять войска». Войско открывается, когда хватает и уровня Ратуши, и уровня Казарм; считаются самые прокачанные Казармы, каждые Казармы по-прежнему дают +1 место в очереди. Уровни Казарм: пехота 1, лучники 2, конница 3, осадные 5 (`EconomyConfig`, `soldier.requiredBarracksLevel`) — **ТЕСТ, согласовать с Димой**; цена улучшения общая, тоже не утверждена. | +| 31.10.2026 | Claude | Откат: Население снова самостоятельный модуль (`Modules/Population`), из Поселения убрано. | | 28.10.2026 | Claude | Население стало частью Поселения (`Settlement/Population`, сборка `Bogatyri.Settlement.Population`) — как на схеме Димы. Подробности — [Population.md](Population.md). | | 27.10.2026 | Claude | Свободная стройка на сетке: `BuildGrid`, `BuildGhost`, `BuildingData.footprint`, `BuildManager.BuildAt` / `CanBuildAt` / `FindData`. Участки и площадки «Застройка» прячутся. Новый `BuildModeController` (призрак, «Поставить / Другое / Отмена»), камера не двигается, пока тащат призрак. | | 19.10.2026 | Claude | `ResetState()` для сброса сохранений. Убранные здания помечаются, чтобы запись в том же кадре их не сохранила. |