using System; using System.Globalization; using System.Text; // МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, сколько они едят и как прирастают. Документ: Docs/Modules/Population.md // Без Unity: правила те же, что будут на сервере. // // Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час) // и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах. // // Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре, // при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же. public sealed class PopulationService : IPopulation, ISaveParticipant { const long SecondsPerHour = 3600; const long StepSeconds = 600; // офлайн-время считаем кусками по 10 минут -- так же, как в игре const double Epsilon = 1e-9; readonly PopulationRules rules; int civilians; int workers; double growthProgress; // накопленная доля следующего человека (0..1) double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды) long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу bool starving; bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение) public PopulationService(PopulationRules rules) { this.rules = rules ?? new PopulationRules(); SetStartState(); } public PopulationRules Rules => rules; public long LastTickUnix => lastTickUnix; public bool RestoredFromSave { get; private set; } // ---------------- Контракт IPopulation ---------------- public int Civilians => civilians; public int Workers => Math.Min(workers, civilians); public int Idle => civilians - Workers; public int Soldiers { get { if (!GameServices.TryGet(out IArmy army)) return 0; return army.SoldiersAtHome + army.TroopsInMarches; } } public int Total => civilians + Soldiers; public int HousingCap { get { int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1; return rules.HousingBase + rules.HousingPerTownHallLevel * (level - 1); } } public float FoodPerHour => (float)FoodPerHourExact(Soldiers); public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour; public bool IsStarving => starving; double FoodPerHourExact(int soldiers) { return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour; } public void Tick(long unixNow) { if (lastTickUnix == 0) { lastTickUnix = unixNow; // первый расчёт: отсюда и считаем Changed(); return; } long elapsed = unixNow - lastTickUnix; if (elapsed < 0) { // Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас" lastTickUnix = unixNow; return; } if (elapsed == 0) return; long max = (long)rules.MaxOfflineHours * SecondsPerHour; if (elapsed > max) elapsed = max; Simulate(elapsed); lastTickUnix = unixNow; } // ---------------- Расчёт ---------------- void Simulate(long seconds) { if (!GameServices.TryGet(out IEconomy economy)) return; // без склада еды считать нечего int totalBefore = Total; bool starvingBefore = starving; int soldiers = Soldiers; long remaining = seconds; while (remaining > 0) { long step = Math.Min(remaining, StepSeconds); remaining -= step; double hours = step / (double)SecondsPerHour; // 1. Едим. Со склада списываются только целые единицы, доли копятся здесь (foodOwed): // склад хранит float, и тысячи мелких списаний по 0.02 давали бы ошибку округления. foodOwed += FoodPerHourExact(soldiers) * hours; int portion = (int)Math.Floor(foodOwed + Epsilon); double have = economy.Get(ResourceType.Food); bool fed; if (portion <= 0) { fed = have >= foodOwed - Epsilon; // пока едим из уже взятого -- голод, только если склад пуст } else if (have >= portion) { economy.Add(ResourceType.Food, -portion); foodOwed -= portion; fed = true; } else { int rest = (int)Math.Floor(have); if (rest > 0) economy.Add(ResourceType.Food, -rest); // доедаем остатки foodOwed = 0; // долг за голодное время не копим fed = false; } starving = !fed; // 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда. if (fed) { int room = HousingCap - (civilians + soldiers); if (room > 0) { growthProgress += rules.GrowthPerHour * hours; int born = (int)Math.Floor(growthProgress + Epsilon); if (born > 0) { growthProgress = Math.Max(0, growthProgress - born); civilians += Math.Min(born, room); } } else { growthProgress = 0; // упёрлись в предел -- не копим "запас" людей } } } if (starving != starvingBefore) EventBus.Publish(new StarvationChangedEvent { IsStarving = starving }); if (Total != totalBefore) Changed(); } // ---------------- Связь с остальной игрой (вызывает App/GameModules) ---------------- // Сохранение загружено: досчитать офлайн-время. Старое сохранение без секции населения -- стартовый набор не выдаём. public void OnGameLoaded(long unixNow) { startingKitGiven = true; Tick(unixNow); Changed(); } // Новая игра (нет файла) или сброс: стартовый набор (войско, ресурсы) выдаётся один раз. // true -- выдать сейчас; false -- уже выдан или это старое сохранение. public bool TryTakeStartingKit() { if (startingKitGiven) return false; startingKitGiven = true; return true; } // Изменилась Армия или Ратуша -- пересчитать общее число людей для интерфейса public void Changed() { EventBus.Publish(new PopulationChangedEvent { Total = Total, Civilians = civilians, Soldiers = Soldiers, HousingCap = HousingCap }); } void SetStartState() { civilians = Math.Max(0, rules.StartCivilians); workers = Math.Min(rules.StartWorkers, civilians); growthProgress = 0; foodOwed = 0; lastTickUnix = 0; starving = false; startingKitGiven = false; } // ---------------- Сохранение (секция "population") ---------------- // Модуль без Unity, поэтому формат -- простые строки "ключ=значение", как у Армии. public string SaveKey => "population"; public string Capture() { var sb = new StringBuilder(); sb.Append("civilians=").Append(civilians.ToString(CultureInfo.InvariantCulture)).Append('\n'); sb.Append("workers=").Append(workers.ToString(CultureInfo.InvariantCulture)).Append('\n'); sb.Append("growth=").Append(growthProgress.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); sb.Append("food=").Append(foodOwed.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); sb.Append("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n'); sb.Append("starving=").Append(starving ? "1" : "0").Append('\n'); sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n'); return sb.ToString(); } public void Restore(string data) { SetStartState(); RestoredFromSave = true; if (string.IsNullOrEmpty(data)) return; foreach (var raw in data.Split('\n')) { string line = raw.Trim(); int eq = line.IndexOf('='); if (eq <= 0) continue; string key = line.Substring(0, eq); string value = line.Substring(eq + 1); switch (key) { case "civilians": civilians = Math.Max(0, ParseInt(value, civilians)); break; case "workers": workers = Math.Max(0, ParseInt(value, workers)); break; case "growth": if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double g)) growthProgress = Math.Max(0, Math.Min(0.999, g)); break; case "food": if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double f)) foodOwed = Math.Max(0, Math.Min(1, f)); break; case "lastTick": if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t); break; case "starving": starving = value == "1"; break; case "kit": startingKitGiven = value == "1"; break; } } } // Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей. public void ResetState() { SetStartState(); Changed(); } static int ParseInt(string s, int fallback) { return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : fallback; } }