Files
MVP1/Assets/Scripts/Modules/Population/PopulationService.cs
T

406 lines
17 KiB
C#
Raw Normal View History

using System;
using System.Globalization;
using System.Text;
// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают.
// Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере.
//
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
// Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях.
//
// Время -- только из 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;
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);
// Доля жителей, способных работать: 120 из 270 в стартовом городе
public int Workforce
{
get
{
int startCiv = Math.Max(1, rules.StartCivilians);
double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv);
return (int)Math.Floor(civilians * share + Epsilon);
}
}
public int FreeWorkers => Math.Max(0, Workforce - 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) + rules.HousingPerHouseLevel * houseLevels;
}
}
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 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();
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; // без склада считать нечего
GameServices.TryGet(out IProduction production);
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;
// 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;
// 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();
}
// ---------------- Рабочие: расстановка ----------------
// Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено.
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)
public void AutoFillAll()
{
bool changed = false;
bool progress = true;
while (FreeWorkers > 0 && progress)
{
progress = false;
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0; 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;
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("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 "kit": startingKitGiven = value == "1"; break;
}
}
}
// Новая игра: стартовый город. Стартовое войско выдаст 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;
}
}