using System; using System.Collections.Generic; using System.Globalization; using System.Text; // МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают. // Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере. // // Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час) // и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах. // Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают // свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей. // // Каждый человек ровно в одной группе (регламент 31.10): // свободный житель = Civilians - Workers (Idle) // рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных) // новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает) // воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию) // Total = Civilians + InTraining + Soldiers -- сумма всех групп. // Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок. // Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять. // // Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре, // при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же. // В каждом куске: сначала добыча (IProduction), потом еда, потом прирост. 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; readonly int[] jobs = new int[WorkJobs.Count]; // рабочих по профессиям bool jobsInitialized; // рабочих хоть раз расставляли (старому сохранению расставим сами) int houseLevels; // сумма уровней Домов в городе (сообщает App/CitySync) double growthProgress; // накопленная доля следующего человека (0..1) double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды) long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу bool starving; int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные // История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас. // Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя. readonly List famine = new List(); 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; } public bool JobsInitialized => jobsInitialized; public int HouseLevels => houseLevels; // ---------------- Контракт IPopulation ---------------- public int Civilians => civilians; public int Workers { get { int n = 0; foreach (var j in jobs) n += j; return n; } } public int Idle => Math.Max(0, civilians - Workers); // Работать может любой житель: общего ограничения нет (регламент 31.10). // Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots). public int Workforce => civilians; public int FreeWorkers => Math.Max(0, Workforce - Workers); public int Soldiers { get { if (!GameServices.TryGet(out IArmy army)) return 0; // раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5) return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal; } } public int Total => civilians + inTraining + Soldiers; // ---------------- Найм воинов ---------------- public int InTraining => inTraining; public int CivilianReserve { get { int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1; int[] table = rules.CivilianReserveByTownHallLevel; if (table == null || table.Length == 0) return 0; return Math.Max(0, table[Math.Min(level, table.Length) - 1]); } } // Берём только свободных (Idle -- не на работе): рабочих сначала надо снять с профессии public int Recruitable => Math.Max(0, Idle - CivilianReserve) / Math.Max(1, rules.PeoplePerSoldier); public bool TryRecruit(int soldiers) { if (soldiers <= 0 || soldiers > Recruitable) return false; int people = soldiers * Math.Max(1, rules.PeoplePerSoldier); civilians -= people; inTraining += people; ClampJobs(); // жителей меньше -- рабочих тоже может стать меньше Changed(); 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; // Заказы, начатые до найма из жителей (старое сохранение), людей не брали -- уходить в минус нельзя inTraining = Math.Max(0, inTraining - soldiers * Math.Max(1, rules.PeoplePerSoldier)); Changed(); } public int HousingCap { get { int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1; return rules.HousingBase + rules.HousingPerTownHallLevel * (level - 1) + rules.HousingPerHouseLevel * houseLevels; } } public float FoodPerHour => (float)FoodPerHourExact(Soldiers); public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour; public bool IsStarving => starving; public bool WasStarvingAt(long unixTime) { if (lastTickUnix == 0 || unixTime >= lastTickUnix) return starving; // ещё не посчитано -- как сейчас foreach (var f in famine) if (unixTime >= f[0] && (f[1] == 0 || unixTime < f[1])) return true; return false; } // Голод начался или кончился в момент unix (вызывается из расчёта на каждом шаге) void RecordFamine(bool nowStarving, long unix) { long[] open = famine.Count > 0 && famine[famine.Count - 1][1] == 0 ? famine[famine.Count - 1] : null; if (nowStarving && open == null) famine.Add(new[] { unix, 0L }); else if (!nowStarving && open != null) open[1] = unix; } // Отрезки, закончившиеся раньше, чем 48 часов назад, Бою уже не понадобятся void PruneFamine(long unixNow) { long keepFrom = unixNow - (long)rules.MaxOfflineHours * SecondsPerHour; famine.RemoveAll(f => f[1] != 0 && f[1] <= keepFrom); } double FoodPerHourExact(int soldiers) { // Обучающиеся в Казармах едят как воины return civilians * rules.FoodPerCivilianPerHour + SoldiersFood(soldiers) + inTraining * rules.FoodPerSoldierPerHour; } // Воины (в городе, в походах и раненые) едят по норме своего типа, если она задана (решения 01.11, п. 4) double SoldiersFood(int soldiers) { var table = rules.FoodPerTroopTypePerHour; int missingNow = GameServices.TryGet(out IArmy a) ? a.MissingTotal : 0; // пропавшие не едят if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return Math.Max(0, soldiers - missingNow) * rules.FoodPerSoldierPerHour; double food = 0; for (int t = 0; t < army.TypeCount; t++) food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour); return food; } public int GetWorkers(WorkJob job) { int j = (int)job; return j >= 0 && j < WorkJobs.Count ? jobs[j] : 0; } public int SetWorkers(WorkJob job, int count) { int j = (int)job; if (j < 0 || j >= WorkJobs.Count) return 0; 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)); if (value != jobs[j]) { jobs[j] = value; jobsInitialized = true; WorkersChanged(); } return jobs[j]; } // Мест для профессии: -1 -- модуль добычи не подключён (тесты без него), ограничения нет static int SlotsFor(WorkJob job) { return GameServices.TryGet(out IProduction production) ? production.Slots(job) : -1; } public void Tick(long unixNow) { if (lastTickUnix == 0) { lastTickUnix = unixNow; // первый расчёт: отсюда и считаем Changed(); SimulatedUntil(unixNow); return; } long elapsed = unixNow - lastTickUnix; if (elapsed < 0) { // Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне). Метку НЕ двигаем назад: // это время уже посчитано, и когда часы снова дойдут до него, оно не начислится второй раз (04.11). // Но сообщить «расчёт дошёл до сейчас» нужно: время до сейчас тоже уже посчитано, и стройки, у которых оно // вышло, завершаются. Иначе после «Сбросить перемотку» все стройки висели на 00:00, пока реальное время // не догонит перемотку (05.11). Ресурсы здесь не начисляются SimulatedUntil(unixNow); return; } if (elapsed == 0) { SimulatedUntil(unixNow); return; } long max = (long)rules.MaxOfflineHours * SecondsPerHour; if (elapsed > max) elapsed = max; Simulate(unixNow - elapsed, elapsed); lastTickUnix = unixNow; PruneFamine(unixNow); SimulatedUntil(unixNow); } // Расчёт дошёл до момента: то, что случается по времени (стройка закончилась), применяется сейчас (GameTimeSimulatedEvent) static void SimulatedUntil(long unixNow) { EventBus.Publish(new GameTimeSimulatedEvent { UnixNow = unixNow }); } // ---------------- Расчёт ---------------- void Simulate(long startUnix, long seconds) { if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего GameServices.TryGet(out IProduction production); int totalBefore = Total; bool starvingBefore = starving; int soldiers = Soldiers; long remaining = seconds; long stepStart = startUnix; while (remaining > 0) { long step = Math.Min(remaining, StepSeconds); remaining -= step; double hours = step / (double)SecondsPerHour; // Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу, // и добыча этого шага считается уже с ними (а не с начала офлайна) SimulatedUntil(stepStart); // 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест production?.Produce(hours); // 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; RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало stepStart += step; // 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда. if (fed) { int room = HousingCap - (civilians + inTraining + 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(); } // ---------------- Рабочие: расстановка ---------------- // Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10). // Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false). // Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки // и улучшения остаются пустыми, пока игрок сам не поставит рабочих. public void AutoFillAll() { bool changed = false; bool progress = true; int cap = Math.Max(Workers, rules.StartWorkers); while (FreeWorkers > 0 && Workers < cap && progress) { progress = false; for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0 && Workers < cap; j++) { int slots = SlotsFor((WorkJob)j); if (slots >= 0 && jobs[j] >= slots) continue; jobs[j]++; progress = changed = true; } } jobsInitialized = true; if (changed) WorkersChanged(); } // Мест стало меньше или работников меньше (призыв в будущем) -- снять лишних public void ClampJobs() { bool changed = false; for (int j = 0; j < WorkJobs.Count; j++) { int slots = SlotsFor((WorkJob)j); if (slots >= 0 && jobs[j] > slots) { jobs[j] = slots; changed = true; } } int over = Workers - Workforce; for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--) { int take = Math.Min(over, jobs[j]); jobs[j] -= take; over -= take; if (take > 0) changed = true; } if (changed) WorkersChanged(); } // Сумма уровней Домов (каждый уровень прибавляет к пределу жилья) public void SetHouseLevels(int levels) { levels = Math.Max(0, levels); if (levels == houseLevels) return; houseLevels = levels; Changed(); } void WorkersChanged() { EventBus.Publish(new WorkersChangedEvent { Workers = Workers, FreeWorkers = FreeWorkers }); } // ---------------- Связь с остальной игрой (вызывает 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); Array.Clear(jobs, 0, jobs.Length); jobsInitialized = false; houseLevels = 0; growthProgress = 0; foodOwed = 0; lastTickUnix = 0; starving = false; inTraining = 0; famine.Clear(); 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'); var parts = new string[jobs.Length]; for (int i = 0; i < jobs.Length; i++) parts[i] = jobs[i].ToString(CultureInfo.InvariantCulture); sb.Append("jobs=").Append(string.Join(",", parts)).Append('\n'); sb.Append("jobsInit=").Append(jobsInitialized ? "1" : "0").Append('\n'); sb.Append("houses=").Append(houseLevels.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("training=").Append(inTraining.ToString(CultureInfo.InvariantCulture)).Append('\n'); // famine=начало-конец;начало-конец (конец 0 -- голод идёт сейчас) var spans = new List(); foreach (var f in famine) spans.Add(f[0].ToString(CultureInfo.InvariantCulture) + "-" + f[1].ToString(CultureInfo.InvariantCulture)); sb.Append("famine=").Append(string.Join(";", spans.ToArray())).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 "jobs": string[] p = value.Split(','); for (int i = 0; i < jobs.Length && i < p.Length; i++) jobs[i] = Math.Max(0, ParseInt(p[i].Trim(), 0)); break; case "jobsInit": jobsInitialized = value == "1"; break; case "houses": houseLevels = Math.Max(0, ParseInt(value, 0)); 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 "training": inTraining = Math.Max(0, ParseInt(value, 0)); break; case "famine": foreach (var span in value.Split(';')) { string[] se = span.Split('-'); if (se.Length == 2 && long.TryParse(se[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fs) && long.TryParse(se[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fe)) famine.Add(new[] { fs, fe }); } break; 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 после сброса всех модулей. public void ResetState() { SetStartState(); Changed(); WorkersChanged(); } static int ParseInt(string s, int fallback) { return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : fallback; } }