Экономика: EconomyConfig, модуль Населения, отладочная панель DEV
This commit is contained in:
@@ -1 +0,0 @@
|
||||
{"TestSuite":"","Date":0,"Player":{"Development":false,"ScreenWidth":0,"ScreenHeight":0,"ScreenRefreshRate":0,"Fullscreen":false,"Vsync":0,"AntiAliasing":0,"Batchmode":false,"RenderThreadingMode":"MultiThreaded","MtRendering":false,"GraphicsJobs":false,"GpuSkinning":false,"Platform":"","ColorSpace":"","AnisotropicFiltering":"","BlendWeights":"","GraphicsApi":"","ScriptingBackend":"IL2CPP","AndroidTargetSdkVersion":"AndroidApiLevelAuto","AndroidBuildSystem":"Gradle","BuildTarget":"Android","StereoRenderingPath":"MultiPass"},"Hardware":{"OperatingSystem":"","DeviceModel":"","DeviceName":"","ProcessorType":"","ProcessorCount":0,"GraphicsDeviceName":"","SystemMemorySizeMB":0},"Editor":{"Version":"6000.5.0f1","Branch":"6000.5/staging","Changeset":"88b47c5e7076","Date":1780938000},"Dependencies":["com.unity.2d.animation@15.1.0","com.unity.2d.aseprite@5.0.3","com.unity.2d.common@14.0.1","com.unity.2d.psdimporter@14.0.3","com.unity.2d.sprite@1.0.0","com.unity.2d.spriteshape@15.0.3","com.unity.2d.tilemap.extras@8.0.3","com.unity.2d.tilemap@1.0.0","com.unity.2d.tooling@3.0.1","com.unity.burst@1.8.29","com.unity.collab-proxy@2.12.4","com.unity.collections@6.5.0","com.unity.ext.nunit@2.1.0","com.unity.ide.rider@3.0.38","com.unity.ide.visualstudio@2.0.26","com.unity.inputsystem@1.19.0","com.unity.mathematics@1.4.0","com.unity.microsoft.gdk.discovery@1.3.0","com.unity.microsoft.gdk.tools@1.7.0","com.unity.microsoft.gdk@1.7.0","com.unity.modules.accessibility@1.0.0","com.unity.modules.adaptiveperformance@1.0.0","com.unity.modules.ai@1.0.0","com.unity.modules.androidjni@1.0.0","com.unity.modules.animation@1.0.0","com.unity.modules.assetbundle@1.0.0","com.unity.modules.audio@1.0.0","com.unity.modules.cloth@1.0.0","com.unity.modules.director@1.0.0","com.unity.modules.hierarchycore@1.0.0","com.unity.modules.imageconversion@1.0.0","com.unity.modules.imgui@1.0.0","com.unity.modules.jsonserialize@1.0.0","com.unity.modules.particlesystem@1.0.0","com.unity.modules.physics@1.0.0","com.unity.modules.physics2d@1.0.0","com.unity.modules.physicscore2d@1.0.0","com.unity.modules.screencapture@1.0.0","com.unity.modules.subsystems@1.0.0","com.unity.modules.terrain@1.0.0","com.unity.modules.terrainphysics@1.0.0","com.unity.modules.tilemap@1.0.0","com.unity.modules.ui@1.0.0","com.unity.modules.uielements@1.0.0","com.unity.modules.umbra@1.0.0","com.unity.modules.unityanalytics@1.0.0","com.unity.modules.unitywebrequest@1.0.0","com.unity.modules.unitywebrequestassetbundle@1.0.0","com.unity.modules.unitywebrequestaudio@1.0.0","com.unity.modules.unitywebrequesttexture@1.0.0","com.unity.modules.unitywebrequestwww@1.0.0","com.unity.modules.vectorgraphics@1.0.0","com.unity.modules.vehicles@1.0.0","com.unity.modules.video@1.0.0","com.unity.modules.wind@1.0.0","com.unity.modules.xr@1.0.0","com.unity.multiplayer.center@1.0.1","com.unity.nuget.mono-cecil@1.11.6","com.unity.nuget.newtonsoft-json@3.2.2","com.unity.purchasing@5.4.2","com.unity.render-pipelines.core@17.5.0","com.unity.render-pipelines.universal@17.5.0","com.unity.render-pipelines.universal-config@17.5.0","com.unity.searcher@4.9.4","com.unity.services.cloud-build@2.0.8","com.unity.services.core@1.18.0","com.unity.shadergraph@17.5.0","com.unity.test-framework.performance@3.5.0","com.unity.test-framework@1.7.0","com.unity.timeline@1.8.12","com.unity.ugui@2.5.0","com.unity.visualscripting@1.9.11"],"Results":[]}
|
||||
@@ -1 +0,0 @@
|
||||
{"MeasurementCount":-1}
|
||||
@@ -0,0 +1,142 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// ОТЛАДОЧНАЯ ПАНЕЛЬ для проверки баланса: кнопка "DEV" слева на экране.
|
||||
//
|
||||
// Есть только в редакторе и в Development Build -- в релизную сборку этот файл не попадает совсем.
|
||||
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
|
||||
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
|
||||
//
|
||||
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение
|
||||
// войск пока идут по реальным часам.
|
||||
public class DebugPanel : MonoBehaviour
|
||||
{
|
||||
static DebugPanel instance;
|
||||
|
||||
GameObject panel;
|
||||
TextMeshProUGUI info;
|
||||
float nextRefresh;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (instance != null) return;
|
||||
var go = new GameObject("DebugPanel", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
|
||||
DontDestroyOnLoad(go);
|
||||
var canvas = go.GetComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 1500; // поверх HUD и окон
|
||||
var scaler = go.GetComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
instance = go.AddComponent<DebugPanel>();
|
||||
instance.Build();
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
// Кнопка-переключатель слева посередине: там нет кнопок HUD
|
||||
var toggle = UIKit.Button(transform, "DEV", new Color(0.55f, 0.15f, 0.55f, 0.85f), Toggle, 110, 64, 26);
|
||||
var trt = (RectTransform)toggle.transform;
|
||||
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
|
||||
trt.pivot = new Vector2(0, 0.5f);
|
||||
trt.anchoredPosition = new Vector2(12, 0);
|
||||
trt.sizeDelta = new Vector2(110, 64);
|
||||
|
||||
var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
||||
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
|
||||
card.pivot = new Vector2(0, 0.5f);
|
||||
card.anchoredPosition = new Vector2(135, 0);
|
||||
card.sizeDelta = new Vector2(620, 0);
|
||||
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
panel = card.gameObject;
|
||||
|
||||
var title = UIKit.Text(card, "ОТЛАДКА БАЛАНСА", 30, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
|
||||
info = UIKit.Text(card, "", 24, UIKit.Theme.Text);
|
||||
|
||||
var row1 = UIKit.Row(card, 70, 10);
|
||||
Btn(row1, "+1 час", UIKit.Theme.Blue, () => GameClock.Advance(3600));
|
||||
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
|
||||
|
||||
var row2 = UIKit.Row(card, 70, 10);
|
||||
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
|
||||
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
|
||||
|
||||
var row3 = UIKit.Row(card, 70, 10);
|
||||
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
|
||||
Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); });
|
||||
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
static void Btn(Transform row, string label, Color color, System.Action onClick)
|
||||
{
|
||||
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
|
||||
UIKit.Size(b, -1, 70, 1);
|
||||
}
|
||||
|
||||
void Toggle()
|
||||
{
|
||||
panel.SetActive(!panel.activeSelf);
|
||||
if (panel.activeSelf) Refresh();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (panel == null || !panel.activeSelf || Time.unscaledTime < nextRefresh) return;
|
||||
nextRefresh = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
if (info == null) return;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
if (GameServices.TryGet(out IPopulation p))
|
||||
{
|
||||
sb.Append($"Население: <b>{p.Total}</b> / {p.HousingCap}\n");
|
||||
sb.Append($" жители {p.Civilians} (рабочие {p.Workers}, остальные {p.Idle}), воины {p.Soldiers}\n");
|
||||
sb.Append($"Еда в час: <color=#ff8a80>-{p.FoodPerHour:0.#}</color> прирост в час: +{p.GrowthPerHour:0.#}\n");
|
||||
if (p.IsStarving) sb.Append("<color=#ff5252><b>ГОЛОД: еды не хватает, прирост остановлен</b></color>\n");
|
||||
}
|
||||
else sb.Append("Модуль Населения не зарегистрирован\n");
|
||||
|
||||
if (GameServices.TryGet(out IEconomy e))
|
||||
sb.Append($"Еда: {e.Get(ResourceType.Food):0.#} дерево {e.Get(ResourceType.Wood):0} камень {e.Get(ResourceType.Stone):0}\n");
|
||||
else sb.Append("Экономика не найдена (нет ResourceManager на сцене)\n");
|
||||
|
||||
long off = GameClock.OffsetSeconds;
|
||||
sb.Append(off > 0 ? $"Перемотано: +{off / 3600f:0.#} ч" : "Время: реальное");
|
||||
info.text = sb.ToString();
|
||||
}
|
||||
|
||||
void AddAllResources()
|
||||
{
|
||||
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
|
||||
foreach (var t in types) AddResource(t, 100);
|
||||
}
|
||||
|
||||
void AddResource(ResourceType type, float amount)
|
||||
{
|
||||
if (GameServices.TryGet(out IEconomy e)) e.Add(type, amount);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void ZeroFood()
|
||||
{
|
||||
if (GameServices.TryGet(out IEconomy e)) e.Add(ResourceType.Food, -e.Get(ResourceType.Food));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (instance == this) instance = null;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2cba3db366a45feaaa3e1f4867ae423
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -22,6 +22,19 @@ public static class GameModules
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
|
||||
|
||||
// Население -- чистый C#: жители, еда, прирост (секция "population"). Числа -- из EconomyConfig.
|
||||
var config = EconomyConfig.Load();
|
||||
var population = new PopulationService(ToPopulationRules(config));
|
||||
GameServices.Register<IPopulation>(population);
|
||||
SaveRegistry.Register(population);
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
|
||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
||||
EventBus.Subscribe<TownHallLevelChangedEvent>(_ => population.Changed()); // предел жилья вырос
|
||||
EventBus.Subscribe<PopulationChangedEvent>(SyncPopulationResource);
|
||||
EventBus.Subscribe<NewGameStartedEvent>(_ => GiveStartingKit(population, army, config));
|
||||
EventBus.Subscribe<GameResetEvent>(_ => GiveStartingKit(population, army, config));
|
||||
|
||||
// Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle")
|
||||
var circle = new CircleService();
|
||||
GameServices.Register<ICircle>(circle);
|
||||
@@ -97,6 +110,53 @@ public static class GameModules
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Население и экономика ----------------
|
||||
|
||||
static PopulationRules ToPopulationRules(EconomyConfig config)
|
||||
{
|
||||
var p = config.population;
|
||||
return new PopulationRules
|
||||
{
|
||||
StartTotal = p.startTotal,
|
||||
StartWorkers = p.startWorkers,
|
||||
StartSoldiers = p.startSoldiers,
|
||||
FoodPerCivilianPerHour = p.foodPerCivilianPerHour,
|
||||
FoodPerSoldierPerHour = p.foodPerSoldierPerHour,
|
||||
GrowthPerHour = p.growthPerHour,
|
||||
HousingBase = p.housingBase,
|
||||
HousingPerTownHallLevel = p.housingPerTownHallLevel,
|
||||
MaxOfflineHours = p.maxOfflineHours
|
||||
};
|
||||
}
|
||||
|
||||
// Новая игра: стартовое войско (пехота) и стартовые ресурсы из EconomyConfig. Один раз на новую игру.
|
||||
static void GiveStartingKit(PopulationService population, ArmyService army, EconomyConfig config)
|
||||
{
|
||||
if (!population.TryTakeStartingKit()) return;
|
||||
|
||||
if (population.Rules.StartSoldiers > 0) army.AddTroops(0, population.Rules.StartSoldiers);
|
||||
|
||||
if (GameServices.TryGet(out IEconomy economy))
|
||||
{
|
||||
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
|
||||
foreach (var t in types)
|
||||
{
|
||||
float target = config.GetStartResource(t);
|
||||
float current = economy.Get(t);
|
||||
if (target > current) economy.Add(t, target - current); // не меньше стартового
|
||||
}
|
||||
}
|
||||
Debug.Log($"[НАСЕЛЕНИЕ] Новая игра: {population.Total} человек, воинов {population.Soldiers}.");
|
||||
}
|
||||
|
||||
// Верхняя панель показывает людей как ресурс "Люди" -- держим его равным населению
|
||||
static void SyncPopulationResource(PopulationChangedEvent e)
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return;
|
||||
float delta = e.Total - economy.Get(ResourceType.Population);
|
||||
if (Mathf.Abs(delta) >= 0.5f) economy.Add(ResourceType.Population, delta);
|
||||
}
|
||||
|
||||
static void OnMarchStarted(MarchStartedEvent e)
|
||||
{
|
||||
var data = GameProgress.Instance.Data;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Слой приложения: "сердцебиение" модуля Населения. Модуль -- чистый C# и не знает про Update,
|
||||
// поэтому раз в несколько секунд его досчитывает этот объект. Живёт между сценами (город <-> карта мира).
|
||||
// Создаётся сам при запуске игры, вешать никуда не нужно.
|
||||
public class PopulationTicker : MonoBehaviour
|
||||
{
|
||||
const float Interval = 2f; // секунд реального времени между расчётами
|
||||
|
||||
static PopulationTicker instance;
|
||||
float next;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (instance != null) return;
|
||||
var go = new GameObject("PopulationTicker");
|
||||
DontDestroyOnLoad(go);
|
||||
instance = go.AddComponent<PopulationTicker>();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
next = Time.unscaledTime + Interval; // первый расчёт -- после загрузки сохранения (она идёт в Start сцены)
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + Interval;
|
||||
if (GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (instance == this) instance = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d7b4f971ff440eaa5744fbe2a1e834d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -11,6 +11,7 @@ public static class CoreUnityBootstrap
|
||||
GameServices.Clear();
|
||||
EventBus.Clear();
|
||||
SaveRegistry.Clear();
|
||||
GameClock.ResetOffset(); // отладочная перемотка не переживает перезапуск
|
||||
EventBus.OnHandlerError = Debug.LogException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,24 @@ public interface ILeveledBuilding
|
||||
int Level { get; set; }
|
||||
}
|
||||
|
||||
// ---------------- Население: жители, еда, прирост ----------------
|
||||
public interface IPopulation
|
||||
{
|
||||
int Total { get; } // все люди города: жители + воины (в городе и в походах)
|
||||
int Civilians { get; } // жители (не воины)
|
||||
int Workers { get; } // жители на работе (этап 3: распределение по зданиям)
|
||||
int Idle { get; } // остальные жители: Civilians - Workers
|
||||
int Soldiers { get; } // воины -- их хранит модуль Армии
|
||||
int HousingCap { get; } // предел населения: выше него прирост не идёт
|
||||
|
||||
float FoodPerHour { get; } // сколько город съедает в час
|
||||
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
|
||||
bool IsStarving { get; } // еды не хватило на последний расчёт
|
||||
|
||||
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
|
||||
void Tick(long unixNow);
|
||||
}
|
||||
|
||||
// ---------------- Армия: войска ----------------
|
||||
public interface IArmy
|
||||
{
|
||||
|
||||
@@ -24,6 +24,30 @@ public struct TownHallLevelChangedEvent
|
||||
// здания, которые появляются кодом (например, Казармы)
|
||||
public struct SettlementRestoringEvent { }
|
||||
|
||||
// ---------------- Время ----------------
|
||||
// Время игры промотано вперёд (отладочная панель). Модули досчитывают прошедшее время.
|
||||
public struct GameTimeAdvancedEvent
|
||||
{
|
||||
public long Seconds;
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// ---------------- Население ----------------
|
||||
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
|
||||
public struct PopulationChangedEvent
|
||||
{
|
||||
public int Total;
|
||||
public int Civilians;
|
||||
public int Soldiers;
|
||||
public int HousingCap;
|
||||
}
|
||||
|
||||
// Голод начался (IsStarving = true) или закончился
|
||||
public struct StarvationChangedEvent
|
||||
{
|
||||
public bool IsStarving;
|
||||
}
|
||||
|
||||
// ---------------- Армия ----------------
|
||||
// Число воинов изменилось (обучение, потери, поход, возвращение, загрузка)
|
||||
public struct TroopsChangedEvent
|
||||
@@ -85,6 +109,8 @@ public struct CircleLeftEvent
|
||||
|
||||
// ---------------- Сохранения ----------------
|
||||
public struct GameLoadedEvent { }
|
||||
// Файла сохранения нет -- это первая игра на устройстве (стартовые ресурсы, стартовое войско)
|
||||
public struct NewGameStartedEvent { }
|
||||
public struct GameSavedEvent { }
|
||||
// Сохранения сброшены, все модули уже в состоянии новой игры
|
||||
public struct GameResetEvent { }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
|
||||
// ЧАСЫ ИГРЫ: единственный источник "сейчас" для игровых правил (население, а дальше -- производство).
|
||||
//
|
||||
// Время игры = реальное время UTC + сдвиг. Сдвиг нужен только отладочной панели ("+1 час"):
|
||||
// перемотка идёт через те же правила, что и офлайн-прогресс, отдельной логики у неё нет.
|
||||
// Сдвиг НЕ сохраняется -- после перезапуска игры время снова реальное.
|
||||
//
|
||||
// Модули берут время только отсюда: GameClock.UnixNow, а не DateTime.UtcNow.
|
||||
public static class GameClock
|
||||
{
|
||||
static long offsetSeconds;
|
||||
|
||||
// Для тестов: подменить "реальное" время. null -- настоящие часы устройства.
|
||||
public static Func<long> RealUnixSource;
|
||||
|
||||
public static long OffsetSeconds => offsetSeconds;
|
||||
|
||||
// Текущее время игры, секунды Unix
|
||||
public static long UnixNow => RealUnix() + offsetSeconds;
|
||||
|
||||
public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime;
|
||||
|
||||
// Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются.
|
||||
public static void Advance(long seconds)
|
||||
{
|
||||
if (seconds <= 0) return;
|
||||
offsetSeconds += seconds;
|
||||
EventBus.Publish(new GameTimeAdvancedEvent { Seconds = seconds, UnixNow = UnixNow });
|
||||
}
|
||||
|
||||
// Вернуть реальное время (сдвиг = 0). Модули воспримут это как "часы ушли назад" и просто продолжат с этого момента.
|
||||
public static void ResetOffset()
|
||||
{
|
||||
offsetSeconds = 0;
|
||||
}
|
||||
|
||||
static long RealUnix()
|
||||
{
|
||||
return RealUnixSource != null ? RealUnixSource() : DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 03f995a4fe964f48bfe8d7b2f7215ecc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2fec63729866dd44483350cddf3c2b84
|
||||
TextScriptImporter:
|
||||
guid: 1df99e6793644da19a8df1e9da262cd6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Bogatyri.Population",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a842e2b03903434ba52aa026b801445
|
||||
TextScriptImporter:
|
||||
guid: 253ae5cb48ed4a4a8153210ae03920f0
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -0,0 +1,25 @@
|
||||
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
|
||||
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
|
||||
public sealed class PopulationRules
|
||||
{
|
||||
// Стартовый город T1: 300 человек = 120 рабочих + 30 воинов + 150 остальных
|
||||
public int StartTotal = 300;
|
||||
public int StartWorkers = 120;
|
||||
public int StartSoldiers = 30;
|
||||
|
||||
// Еда в час
|
||||
public double FoodPerCivilianPerHour = 0.1;
|
||||
public double FoodPerSoldierPerHour = 0.2;
|
||||
|
||||
// Прирост +2–3 человека в час, если есть еда
|
||||
public double GrowthPerHour = 2.5;
|
||||
|
||||
// Предел населения (жильё). Растёт с уровнем Ратуши. Числа НЕ утверждены командой.
|
||||
public int HousingBase = 400;
|
||||
public int HousingPerTownHallLevel = 100;
|
||||
|
||||
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
|
||||
public int MaxOfflineHours = 48;
|
||||
|
||||
public int StartCivilians => StartTotal - StartSoldiers;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30bbbd3ca6154dda923f86163c3b90a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,270 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46182f0762894c3aab9763821d2c9c19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,6 +16,7 @@ public class GameBootstrap : MonoBehaviour
|
||||
else
|
||||
{
|
||||
Debug.Log("[СТАРТ] Сохранения нет, начинаем новую игру.");
|
||||
EventBus.Publish(new NewGameStartedEvent()); // стартовые ресурсы и войско -- в App/GameModules
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"UnityEditor.TestRunner",
|
||||
"Bogatyri.Core",
|
||||
"Bogatyri.Army",
|
||||
"Bogatyri.Population",
|
||||
"Bogatyri.Battle",
|
||||
"Bogatyri.World.Simulation",
|
||||
"Bogatyri.Chat",
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
|
||||
// Тесты модуля Населения (этап 2). Запуск: Window -> General -> Test Runner -> EditMode -> Run All.
|
||||
// Цифры -- из дизайн-документа: стартовый город 300 человек (из них 30 воинов) ест 33 еды в час.
|
||||
public class PopulationTests
|
||||
{
|
||||
const long T0 = 1_800_000_000; // произвольный момент времени, секунды Unix
|
||||
const long Hour = 3600;
|
||||
|
||||
FakeEconomy economy;
|
||||
FakeSettlement settlement;
|
||||
ArmyService army;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
GameServices.Clear();
|
||||
EventBus.Clear();
|
||||
SaveRegistry.Clear();
|
||||
GameClock.ResetOffset();
|
||||
GameClock.RealUnixSource = null;
|
||||
|
||||
economy = new FakeEconomy();
|
||||
settlement = new FakeSettlement { TownHallLevel = 1 };
|
||||
army = new ArmyService();
|
||||
GameServices.Register<IEconomy>(economy);
|
||||
GameServices.Register<ISettlement>(settlement);
|
||||
GameServices.Register<IArmy>(army);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
GameClock.RealUnixSource = null;
|
||||
GameClock.ResetOffset();
|
||||
}
|
||||
|
||||
// Стартовый город: 270 жителей + 30 воинов в Армии
|
||||
PopulationService StartCity(PopulationRules rules = null)
|
||||
{
|
||||
var p = new PopulationService(rules ?? new PopulationRules());
|
||||
army.AddTroops(0, p.Rules.StartSoldiers);
|
||||
p.Tick(T0); // первый расчёт только запоминает время
|
||||
return p;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartCity_Has300People_AndEats33FoodPerHour()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 1000);
|
||||
var p = StartCity();
|
||||
|
||||
Assert.AreEqual(300, p.Total);
|
||||
Assert.AreEqual(270, p.Civilians);
|
||||
Assert.AreEqual(120, p.Workers);
|
||||
Assert.AreEqual(150, p.Idle);
|
||||
Assert.AreEqual(30, p.Soldiers);
|
||||
Assert.AreEqual(33f, p.FoodPerHour, 0.001f);
|
||||
|
||||
p.Tick(T0 + Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "за час город съел 33 еды");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithFood_GrowsTwoAndHalfPerHour()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
var p = StartCity();
|
||||
|
||||
p.Tick(T0 + 10 * Hour);
|
||||
Assert.AreEqual(300 + 25, p.Total, "за 10 часов +25 человек");
|
||||
Assert.IsFalse(p.IsStarving);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoFood_GrowthStops_AndStarvationStarts()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 0);
|
||||
var p = StartCity();
|
||||
bool? starvingEvent = null;
|
||||
EventBus.Subscribe<StarvationChangedEvent>(e => starvingEvent = e.IsStarving);
|
||||
|
||||
p.Tick(T0 + 10 * Hour);
|
||||
|
||||
Assert.AreEqual(300, p.Total, "без еды прироста нет");
|
||||
Assert.IsTrue(p.IsStarving);
|
||||
Assert.AreEqual(true, starvingEvent);
|
||||
Assert.AreEqual(0f, p.GrowthPerHour);
|
||||
Assert.AreEqual(0, economy.Get(ResourceType.Food), 0.0001);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FoodRunsOutMidway_EatsTheRest_ThenStarves()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 50); // хватит на полтора часа
|
||||
var p = StartCity();
|
||||
|
||||
p.Tick(T0 + 3 * Hour);
|
||||
Assert.AreEqual(0, economy.Get(ResourceType.Food), 0.0001, "остатки доедены");
|
||||
Assert.IsTrue(p.IsStarving);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FoodReturns_StarvationEnds_GrowthResumes()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 0);
|
||||
var p = StartCity();
|
||||
p.Tick(T0 + Hour);
|
||||
Assert.IsTrue(p.IsStarving);
|
||||
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
p.Tick(T0 + 5 * Hour);
|
||||
Assert.IsFalse(p.IsStarving);
|
||||
Assert.Greater(p.Total, 300);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HousingCap_StopsGrowth_HigherTownHall_ResumesIt()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
var p = StartCity(new PopulationRules { HousingBase = 300, HousingPerTownHallLevel = 100 });
|
||||
|
||||
p.Tick(T0 + 10 * Hour);
|
||||
Assert.AreEqual(300, p.Total, "300 жителей при пределе 300 -- прирост стоит");
|
||||
Assert.AreEqual(0f, p.GrowthPerHour);
|
||||
|
||||
settlement.TownHallLevel = 2; // предел 400
|
||||
p.Tick(T0 + 20 * Hour);
|
||||
Assert.AreEqual(325, p.Total, "предел вырос -- прирост пошёл");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Growth_NeverExceedsHousingCap()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
var p = StartCity(new PopulationRules { HousingBase = 305 });
|
||||
|
||||
p.Tick(T0 + 40 * Hour);
|
||||
Assert.AreEqual(305, p.Total);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Offline_IsCappedAtMaxHours()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
var p = StartCity(new PopulationRules { MaxOfflineHours = 48 });
|
||||
|
||||
p.Tick(T0 + 100 * Hour);
|
||||
double eaten = 100000 - economy.Get(ResourceType.Food);
|
||||
Assert.Greater(eaten, 33 * 48 - 1, "посчитано 48 часов");
|
||||
Assert.Less(eaten, 33 * 60, "а не 100 (за 48 часов прибавилось 120 человек -- ели чуть больше 33 в час)");
|
||||
Assert.AreEqual(T0 + 100 * Hour, p.LastTickUnix, "дальше считаем от 'сейчас'");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ManySmallTicks_EqualOneBigTick()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
var a = StartCity();
|
||||
for (long t = T0 + 2; t <= T0 + 10 * Hour; t += 2) a.Tick(t); // как в игре: раз в 2 секунды
|
||||
float foodA = economy.Get(ResourceType.Food);
|
||||
int totalA = a.Total;
|
||||
|
||||
SetUp();
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
var b = StartCity();
|
||||
b.Tick(T0 + 10 * Hour); // как офлайн-прогресс
|
||||
Assert.AreEqual(totalA, b.Total);
|
||||
Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 3.0, "игра и офлайн считают одинаково (разница -- доли часа, когда родился человек)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClockGoesBack_NothingHappens_ThenContinues()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 1000);
|
||||
var p = StartCity();
|
||||
|
||||
p.Tick(T0 - 5000);
|
||||
Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "время назад -- ничего не съедено");
|
||||
Assert.AreEqual(300, p.Total);
|
||||
|
||||
p.Tick(T0 - 5000 + Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DebugAdvance_GoesThroughTheSameRules()
|
||||
{
|
||||
long real = T0;
|
||||
GameClock.RealUnixSource = () => real;
|
||||
economy.Set(ResourceType.Food, 1000);
|
||||
var p = StartCity();
|
||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => p.Tick(e.UnixNow)); // как в App/GameModules
|
||||
|
||||
GameClock.Advance(Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "+1 час = час обычных правил");
|
||||
Assert.AreEqual(Hour, GameClock.OffsetSeconds);
|
||||
|
||||
GameClock.ResetOffset(); // сдвиг сброшен -- время "назад", ничего не ломается
|
||||
p.Tick(GameClock.UnixNow);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveAndRestore_KeepsEverything()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
var p = StartCity();
|
||||
p.Tick(T0 + 3 * Hour); // +7 человек и доля восьмого
|
||||
string saved = p.Capture();
|
||||
|
||||
var q = new PopulationService(new PopulationRules());
|
||||
q.Restore(saved);
|
||||
Assert.IsTrue(q.RestoredFromSave);
|
||||
Assert.AreEqual(p.Civilians, q.Civilians);
|
||||
Assert.AreEqual(p.Workers, q.Workers);
|
||||
Assert.AreEqual(p.LastTickUnix, q.LastTickUnix);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartingKit_IsGivenOnce_AndNotToOldSaves()
|
||||
{
|
||||
var fresh = new PopulationService(new PopulationRules());
|
||||
Assert.IsTrue(fresh.TryTakeStartingKit(), "новая игра -- выдать");
|
||||
Assert.IsFalse(fresh.TryTakeStartingKit(), "второй раз -- нет");
|
||||
|
||||
var old = new PopulationService(new PopulationRules());
|
||||
old.OnGameLoaded(T0); // старое сохранение без секции населения
|
||||
Assert.IsFalse(old.TryTakeStartingKit(), "старому игроку стартовый набор не выдаётся");
|
||||
|
||||
old.ResetState(); // сброс прогресса -- новая игра
|
||||
Assert.IsTrue(old.TryTakeStartingKit());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reset_ReturnsToStartCity()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
var p = StartCity();
|
||||
p.Tick(T0 + 10 * Hour);
|
||||
p.ResetState();
|
||||
Assert.AreEqual(270, p.Civilians);
|
||||
Assert.AreEqual(0, p.LastTickUnix);
|
||||
Assert.IsFalse(p.IsStarving);
|
||||
}
|
||||
|
||||
// ---------------- Заглушки модулей ----------------
|
||||
|
||||
class FakeEconomy : IEconomy
|
||||
{
|
||||
readonly Dictionary<ResourceType, float> res = new Dictionary<ResourceType, float>();
|
||||
public void Set(ResourceType t, float v) { res[t] = v; }
|
||||
public float Get(ResourceType type) => res.TryGetValue(type, out float v) ? v : 0f;
|
||||
public bool Has(ResourceType type, float amount) => Get(type) >= amount;
|
||||
public void Add(ResourceType type, float amount) { float v = Get(type) + amount; res[type] = v < 0 ? 0 : v; }
|
||||
public bool TrySpend(ResourceType type, float amount) { if (!Has(type, amount)) return false; Add(type, -amount); return true; }
|
||||
public bool CanAfford(IReadOnlyList<ResourceAmount> cost) { foreach (var c in cost) if (!Has(c.Type, c.Amount)) return false; return true; }
|
||||
public bool TrySpend(IReadOnlyList<ResourceAmount> cost) { if (!CanAfford(cost)) return false; foreach (var c in cost) Add(c.Type, -c.Amount); return true; }
|
||||
}
|
||||
|
||||
class FakeSettlement : ISettlement
|
||||
{
|
||||
public int TownHallLevel { get; set; }
|
||||
public int BuildingCount => 0;
|
||||
public bool IsCityLoaded => true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6570cf436af4ee1bc60a25c8714b844
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user