Files
MVP1/Assets/Scripts/Modules/Tests/ProductionTests.cs
AlexanderandClaude Opus 5.5 157d3ba510 Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух, пауза при полном хранилище (ответ 06.11, п. 3)
- CrystalMine (без Unity) + 5 тестов: ровно 3 за 7 суток, ×12 не действует, 49/50 -> 1, пауза, перезагрузка и часы назад без двойного начисления, офлайн-стройка
- Работает с конца стройки, офлайн досчитывается; вне недельного PvE-предела
- Вторая шахта -- Ратуша 9 (ТЕСТ); окно здания: следующий кристалл / пауза
- DEV «Ресурсы 100 000» -- обычные ресурсы мимо лимита склада

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:31:47 +01:00

736 lines
34 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using NUnit.Framework;
// Тесты добычи через рабочих (этап 3) и лимитов склада (этап 1).
// Запуск: Window -> General -> Test Runner -> EditMode -> Run All.
// Проверка из дизайн-документа: сбалансированный город даёт 90 еды, 50 дерева, 22.5 камня и 15 металла в час.
public class ProductionTests
{
const long T0 = 1_800_000_000;
const long Hour = 3600;
PopulationTests.FakeEconomy economy;
ArmyService army;
PopulationService population;
ProductionService production;
[SetUp]
public void SetUp()
{
GameServices.Clear();
EventBus.Clear();
SaveRegistry.Clear();
GameClock.ResetOffset();
economy = new PopulationTests.FakeEconomy();
army = new ArmyService();
population = new PopulationService(new PopulationRules());
production = new ProductionService(new ProductionRules());
GameServices.Register<IEconomy>(economy);
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
GameServices.Register<IArmy>(army);
GameServices.Register<IPopulation>(population);
GameServices.Register<IProduction>(production);
army.AddTroops(0, 30);
population.Tick(T0);
}
// По одному зданию 1 уровня на каждую профессию, кроме золота (нужно 2 фермы для 45 фермеров)
static List<KeyValuePair<WorkJob, int>> Buildings(params (WorkJob job, int level)[] list)
{
var result = new List<KeyValuePair<WorkJob, int>>();
foreach (var b in list) result.Add(new KeyValuePair<WorkJob, int>(b.job, b.level));
return result;
}
[Test]
public void BalancedCity_Gives90Food_50Wood_22_5Stone_15Iron()
{
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1),
(WorkJob.Stonecutter, 1), (WorkJob.MinerIron, 1)));
population.SetWorkers(WorkJob.Farmer, 45);
population.SetWorkers(WorkJob.Woodcutter, 25);
population.SetWorkers(WorkJob.Stonecutter, 15);
population.SetWorkers(WorkJob.MinerIron, 15);
Assert.AreEqual(90f, production.RatePerHour(ResourceType.Food), 0.001f);
Assert.AreEqual(50f, production.RatePerHour(ResourceType.Wood), 0.001f);
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(170, population.FreeWorkers, "работать может любой из 270 жителей");
}
[Test]
public void OneHour_AddsProduction_AndFoodIsNetOfEating()
{
economy.Set(ResourceType.Food, 100);
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Farmer, 45);
population.SetWorkers(WorkJob.Woodcutter, 25);
population.Tick(T0 + Hour);
Assert.AreEqual(100 + 90 - 33, economy.Get(ResourceType.Food), 0.01, "+90 добыли, -33 съели");
Assert.AreEqual(50, economy.Get(ResourceType.Wood), 0.01);
}
[Test]
public void ProductionComesBeforeEating_NoFalseStarvationOffline()
{
economy.Set(ResourceType.Food, 0); // склад пуст, но фермы кормят город
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
population.SetWorkers(WorkJob.Farmer, 45);
population.Tick(T0 + 10 * Hour);
Assert.IsFalse(population.IsStarving, "сначала добыли, потом съели");
Assert.AreEqual((90 - 33) * 10, economy.Get(ResourceType.Food), 1.0);
}
[Test]
public void Workers_LimitedBySlots_AndByFreeWorkers()
{
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест
Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить");
var farms = new List<KeyValuePair<WorkJob, int>> { new KeyValuePair<WorkJob, int>(WorkJob.Woodcutter, 1) };
for (int i = 0; i < 9; i++) farms.Add(new KeyValuePair<WorkJob, int>(WorkJob.Farmer, 1)); // 270 мест
production.SetBuildings(farms);
Assert.AreEqual(240, population.SetWorkers(WorkJob.Farmer, 270), "свободных жителей осталось 240 из 270");
Assert.AreEqual(0, population.FreeWorkers);
}
[Test]
public void NoBuilding_NoWorkers()
{
Assert.AreEqual(0, population.SetWorkers(WorkJob.MinerGold, 10), "нет Золотого рудника -- нет мест");
Assert.AreEqual(0f, production.RatePerHour(ResourceType.Gold));
}
[Test]
public void HigherLevel_MoreSlots_AndEfficiency()
{
production.SetBuildings(Buildings((WorkJob.Woodcutter, 3))); // 30 + 2×10 мест, норма ×1.2
Assert.AreEqual(50, production.Slots(WorkJob.Woodcutter));
Assert.AreEqual(1.2f, production.Efficiency(WorkJob.Woodcutter), 0.0001f);
population.SetWorkers(WorkJob.Woodcutter, 50);
Assert.AreEqual(50 * 2 * 1.2f, production.RatePerHour(ResourceType.Wood), 0.001f);
}
[Test]
public void StorageCap_OverflowIsLost()
{
economy.SetCapacity(ResourceType.Wood, 1000);
economy.Set(ResourceType.Wood, 990);
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 в час
population.Tick(T0 + Hour);
Assert.AreEqual(1000, economy.Get(ResourceType.Wood), 0.0001, "склад полон -- лишнее пропало");
}
[Test]
public void AutoFillAll_SpreadsWorkersEvenly()
{
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Stonecutter, 1)));
population.AutoFillAll();
Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter));
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter));
Assert.AreEqual(180, population.FreeWorkers);
Assert.IsTrue(population.JobsInitialized);
}
[Test]
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)));
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]
public void FewerSlots_ClampJobs_RemovesExtraWorkers()
{
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
population.SetWorkers(WorkJob.Farmer, 60);
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
population.ClampJobs();
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
Assert.AreEqual(240, population.FreeWorkers);
}
[Test]
public void ManySmallTicks_EqualOneBigTick_WithProduction()
{
economy.Set(ResourceType.Food, 500);
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Farmer, 30);
population.SetWorkers(WorkJob.Woodcutter, 30);
for (long t = T0 + 2; t <= T0 + 5 * Hour; t += 2) population.Tick(t);
float foodA = economy.Get(ResourceType.Food), woodA = economy.Get(ResourceType.Wood);
SetUp();
economy.Set(ResourceType.Food, 500);
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Farmer, 30);
population.SetWorkers(WorkJob.Woodcutter, 30);
population.Tick(T0 + 5 * Hour);
Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 1.0);
Assert.AreEqual(woodA, economy.Get(ResourceType.Wood), 1.0);
Assert.AreEqual(300, woodA, 1.0, "30 лесорубов × 2 × 5 часов");
}
[Test]
public void SaveAndRestore_KeepsSlots()
{
production.SetBuildings(Buildings((WorkJob.Stonecutter, 2)));
var copy = new ProductionService(new ProductionRules());
copy.Restore(production.Capture());
Assert.AreEqual(40, copy.Slots(WorkJob.Stonecutter));
Assert.AreEqual(1.1f, copy.Efficiency(WorkJob.Stonecutter), 0.0001f);
}
// ---------------- Офлайн по временным меткам (решения 04.11) ----------------
// Стройка, закончившаяся в офлайне, вступает в силу в момент расчёта, когда время дошло до её конца
// (GameTimeSimulatedEvent). В игре это делает App/ConstructionClock; здесь -- подписка в тесте.
void CompleteUpgradeAt(long atUnix, List<KeyValuePair<WorkJob, int>> after)
{
bool done = false;
EventBus.Subscribe<GameTimeSimulatedEvent>(e =>
{
if (done || e.UnixNow < atUnix) return;
done = true;
production.SetBuildings(after);
});
}
[Test]
public void Offline_UpgradeFinishedMidway_ProducesNewRateOnlyFromCompletion()
{
economy.Set(ResourceType.Food, 100000);
economy.Set(ResourceType.Wood, 0);
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час, на 2 уровне -- 66
CompleteUpgradeAt(T0 + 2 * Hour, Buildings((WorkJob.Woodcutter, 2)));
population.Tick(T0 + 4 * Hour); // 4 часа офлайна, улучшение закончилось через 2 часа
Assert.AreEqual(2 * 60 + 2 * 66, economy.Get(ResourceType.Wood), 0.01f,
"2 часа по 60 + 2 часа по 66, а не все 4 часа по новой норме");
}
[Test]
public void Offline_FinishedInsideStep_CountsFromNextStep_NeverEarlier()
{
economy.Set(ResourceType.Food, 100000);
economy.Set(ResourceType.Wood, 0);
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Woodcutter, 30);
CompleteUpgradeAt(T0 + 2 * Hour + 5 * 60, Buildings((WorkJob.Woodcutter, 2))); // посреди 10-минутного шага
population.Tick(T0 + 4 * Hour);
float wood = economy.Get(ResourceType.Wood);
Assert.LessOrEqual(wood, 125 / 60f * 60 + 115 / 60f * 66 + 0.01f, "не больше, чем если бы новая норма шла ровно с конца стройки");
Assert.AreEqual(130 / 60f * 60 + 110 / 60f * 66, wood, 0.01f, "с ближайшего шага расчёта после конца стройки");
}
[Test]
public void Tick_ClockWentBack_StillReportsSimulatedTime_NoResources()
{
// «+1 час», потом «Сбросить перемотку»: часы ушли назад. Стройка, у которой время вышло, должна завершиться
// (событие о расчёте приходит), а ресурсы за уже посчитанный час второй раз не начисляются (05.11)
economy.Set(ResourceType.Food, 100000);
economy.Set(ResourceType.Wood, 0);
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Woodcutter, 30);
population.Tick(T0);
population.Tick(T0 + Hour);
float wood = economy.Get(ResourceType.Wood);
long reported = 0;
EventBus.Subscribe<GameTimeSimulatedEvent>(e => reported = e.UnixNow);
population.Tick(T0 + 60);
Assert.AreEqual(T0 + 60, reported, "расчёт сообщает, до какого момента дошло время -- стройки завершаются");
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.01f, "за уже посчитанное время ресурсов не прибавилось");
}
[Test]
public void Tick_SameMomentTwice_NoExtraResources()
{
economy.Set(ResourceType.Food, 100000);
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Woodcutter, 30);
population.Tick(T0 + Hour);
float wood = economy.Get(ResourceType.Wood);
population.Tick(T0 + Hour);
population.Tick(T0 + Hour - 60); // часы ушли назад -- ничего не начисляется
population.Tick(T0 + Hour);
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f);
}
[Test]
public void Restore_SameMoment_NoSecondCredit()
{
economy.Set(ResourceType.Food, 100000);
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Woodcutter, 30);
population.Tick(T0 + Hour);
float wood = economy.Get(ResourceType.Wood);
var copy = new PopulationService(new PopulationRules());
copy.Restore(population.Capture()); // как загрузка сохранения: метка последнего расчёта та же
GameServices.Register<IPopulation>(copy);
copy.Tick(T0 + Hour);
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f, "загрузка не начисляет то же время второй раз");
copy.Tick(T0 + 2 * Hour);
Assert.AreEqual(wood + 60, economy.Get(ResourceType.Wood), 0.01f, "а следующий час -- ровно один раз");
}
[Test]
public void SimulatedEvents_GoForwardStepByStep_EndAtNow()
{
var times = new List<long>();
EventBus.Subscribe<GameTimeSimulatedEvent>(e => times.Add(e.UnixNow));
population.Tick(T0 + Hour);
Assert.AreEqual(T0 + Hour, times[times.Count - 1], "последнее событие -- момент Tick");
for (int i = 1; i < times.Count; i++) Assert.GreaterOrEqual(times[i], times[i - 1], "время расчёта не идёт назад");
Assert.AreEqual(7, times.Count, "6 шагов по 10 минут + конец");
}
}
// Синие кристаллы при пределе 50 (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 6)
public class CrystalRewardsTests
{
const float Cap = 50f;
[Test]
public void Crystals_49of50_Reward2_OneGiven_OneWaits()
{
var r = new CrystalRewards();
Assert.AreEqual(1, r.Claim("q1", 2, 49, Cap), "49/50 + 2 -> выдан 1");
Assert.AreEqual(1, r.Left("q1"), "1 ждёт места");
}
[Test]
public void Crystals_Full_OldReward15_NothingGiven_NothingLost()
{
var r = new CrystalRewards();
Assert.AreEqual(0, r.Claim("old", 15, 50, Cap), "50/50 + 15 из старых данных -> 0 выдано");
Assert.AreEqual(15, r.Left("old"), "награда ждёт целиком, ничего не исчезло");
Assert.AreEqual(10, r.Claim("old", 15, 40, Cap), "освободили 10 -- выдано 10");
Assert.AreEqual(5, r.Left("old"));
}
[Test]
public void Crystals_RepeatClaim_NeverGivesTwice()
{
var r = new CrystalRewards();
r.Claim("q", 2, 49, Cap); // выдан 1, ждёт 1
Assert.AreEqual(0, r.Claim("q", 2, 50, Cap), "повторное «Забрать» при 50/50 -- ничего");
Assert.AreEqual(1, r.Left("q"), "остаток не вырос: награда одна");
Assert.AreEqual(1, r.Claim("q", 2, 30, Cap), "место есть -- выдан только остаток 1, а не 2");
Assert.AreEqual(0, r.Left("q"));
}
[Test]
public void Crystals_AtMostTwoWaiting_ThirdNotStarted_FittingAlwaysOk()
{
var r = new CrystalRewards();
r.Claim("a", 2, 50, Cap);
r.Claim("b", 2, 50, Cap);
Assert.AreEqual(2, r.WaitingCount);
Assert.IsFalse(r.CanStart("c", 2, 50, Cap), "третья награда при 50/50 не выдаётся -- задание остаётся «Забрать»");
Assert.IsTrue(r.CanStart("a", 2, 50, Cap), "ждущую можно дозабирать");
Assert.IsTrue(r.CanStart("c", 2, 40, Cap), "помещается целиком -- место ожидания не нужно");
Assert.IsTrue(r.CanStart("d", 0, 50, Cap), "награда без кристаллов -- всегда");
}
[Test]
public void Crystals_Waiting_SurvivesSaveAndLoad()
{
var r = new CrystalRewards();
r.Claim("m:m_th_3", 2, 50, Cap);
r.Claim("mail_7", 5, 48, Cap);
var copy = new CrystalRewards();
copy.Load(r.ToLines());
Assert.AreEqual(2, copy.Left("m:m_th_3"));
Assert.AreEqual(3, copy.Left("mail_7"));
Assert.AreEqual(2, copy.WaitingCount);
}
[Test]
public void Crystals_NeverAboveCap()
{
Assert.AreEqual(0, CrystalRewards.Free(50, Cap));
Assert.AreEqual(0, CrystalRewards.Free(51, Cap), "выше предела (старые данные) -- места нет, а не минус");
Assert.AreEqual(3, CrystalRewards.Fits(10, 47, Cap));
Assert.AreEqual(10, CrystalRewards.Fits(10, 0, float.PositiveInfinity), "без предела -- целиком");
}
}
// Хранилище синих кристаллов: платное расширение по уровню Ратуши (регламент валют 05.11, разд. 8 и 12)
public class CrystalStorageTests
{
static CrystalStorage Storage() => new CrystalStorage(50, new[]
{
new CrystalStorage.Tier { TownHallLevel = 3, Capacity = 75, Cost = new[] { new ResourceAmount(ResourceType.Gold, 2000) } },
new CrystalStorage.Tier { TownHallLevel = 5, Capacity = 100, Cost = new[] { new ResourceAmount(ResourceType.Gold, 4000) } },
new CrystalStorage.Tier { TownHallLevel = 7, Capacity = 150, Cost = new[] { new ResourceAmount(ResourceType.Gold, 7000) } },
new CrystalStorage.Tier { TownHallLevel = 9, Capacity = 200, Cost = new[] { new ResourceAmount(ResourceType.Gold, 10000) } },
});
[Test]
public void Storage_NotAutomatic_TownHallOnlyAllows()
{
var s = Storage();
Assert.AreEqual(50, s.Capacity, "старт 50");
int spent = 0;
Assert.IsFalse(s.TryExpand(2, c => { spent++; return true; }), "Ратуша 2 -- расширения нет");
Assert.AreEqual(0, spent, "ничего не списано");
Assert.AreEqual(50, s.Capacity, "Ратуша 3 сама вместимость не меняет -- только разрешает");
Assert.IsNull(s.WhyNot(3));
}
[Test]
public void Storage_50to75_PaidOnce_AndKeptAfterSaveLoad()
{
var s = Storage();
float gold = 2500;
System.Func<IReadOnlyList<ResourceAmount>, bool> pay = cost =>
{
float need = 0;
foreach (var c in cost) if (c.Type == ResourceType.Gold) need += c.Amount;
if (gold < need) return false;
gold -= need;
return true;
};
Assert.IsTrue(s.TryExpand(3, pay));
Assert.AreEqual(75, s.Capacity, "50 -> 75 после оплаты");
Assert.AreEqual(500, gold, 0.01, "списано один раз");
Assert.IsFalse(s.TryExpand(3, pay), "повторное нажатие на Ратуше 3 -- следующая ступень ещё закрыта");
Assert.AreEqual(500, gold, 0.01, "второй раз не списано");
var loaded = Storage();
loaded.SetLevel(s.Level);
Assert.AreEqual(75, loaded.Capacity, "после сохранения и загрузки -- 75");
}
[Test]
public void Storage_NotEnoughResources_NothingChanges()
{
var s = Storage();
Assert.IsFalse(s.TryExpand(9, c => false), "не хватило -- списание не прошло");
Assert.AreEqual(0, s.Level);
Assert.AreEqual(50, s.Capacity);
}
[Test]
public void Storage_TiersInOrder_UpTo200()
{
var s = Storage();
for (int i = 0; i < 4; i++) Assert.IsTrue(s.TryExpand(10, c => true));
Assert.AreEqual(200, s.Capacity);
Assert.IsNull(s.Next);
Assert.IsFalse(s.TryExpand(10, c => true), "дальше 200 -- нет");
s.SetLevel(99);
Assert.AreEqual(4, s.Level, "лишние ступени из сохранения обрезаются");
}
[Test]
public void Storage_Waiting_ClaimedOnceAfterExpansion()
{
// 49/50 + допустимая награда 2 -> 1 сразу и 1 ждёт; после расширения ждущий забирается один раз
var s = Storage();
var r = new CrystalRewards();
float have = 49;
have += r.Claim("q", 2, have, s.Capacity);
Assert.AreEqual(50, have, 0.01);
Assert.AreEqual(1, r.Left("q"));
s.TryExpand(3, c => true);
have += r.Claim("q", 2, have, s.Capacity);
Assert.AreEqual(51, have, 0.01, "после расширения выдан ждущий 1");
Assert.AreEqual(0, r.Left("q"), "больше ничего не ждёт -- выдан ровно один раз");
}
}
// Недельный PvE-предел синих кристаллов (регламент валют 05.11, разд. 5.2, 10, 12; ответ 06.11, п. 2)
public class CrystalQuotaTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
[Test]
public void Quota_5of6_Reward2_OneCrystal_OneCompensated()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(5, q.Take("a", 5, T0));
Assert.AreEqual(1, q.Take("b", 2, T0 + 1000), "5/6 + 2 -> 1 кристалл (второй -- компенсацией)");
Assert.AreEqual(0, q.Left(T0 + 1000));
}
[Test]
public void Quota_6of6_Purchase_Blocked()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.IsFalse(q.CanTakeAll(1, T0 + 1), "6/6 -- покупка у торговца блокируется (золото не списывается)");
Assert.AreEqual(0, q.Take("b", 1, T0 + 1));
}
[Test]
public void Quota_SameOperationTwice_CountedOnce()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(2, q.Take("event_5", 2, T0));
Assert.AreEqual(0, q.Take("event_5", 2, T0 + 5), "повторное «Забрать» -- квота не берётся второй раз");
Assert.AreEqual(2, q.Used(T0 + 5));
}
[Test]
public void Quota_RollingWindow_FreesAfter7Days_NoCarryOver()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.AreEqual(0, q.Left(T0 + 7 * Day - 1), "ещё не прошло 7 суток");
Assert.AreEqual(T0 + 7 * Day, q.NextFreeMs(T0 + Day));
Assert.AreEqual(6, q.Left(T0 + 7 * Day), "через 7 суток квота снова 6, а не 6 + перенос");
Assert.AreEqual(6, q.Take("b", 9, T0 + 7 * Day), "сверх 6 -- компенсацией, на следующую неделю не переносится");
}
[Test]
public void Quota_SurvivesSaveAndLoad()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 4, T0);
var copy = new CrystalQuota(6, 7 * Day);
copy.Load(q.ToLines());
Assert.AreEqual(2, copy.Left(T0 + Day), "после перезапуска -- те же 4 из 6");
Assert.IsTrue(copy.Taken("a"));
}
[Test]
public void Quota_SeparateFromStorage_WaitingNotCountedTwice()
{
// Квота есть, а места нет: выданное по квоте ждёт места и второй раз квоту не тратит
var q = new CrystalQuota(6, 7 * Day);
var r = new CrystalRewards();
int byQuota = q.Take("q1", 2, T0);
Assert.AreEqual(2, byQuota);
Assert.AreEqual(0, r.Claim("q1", byQuota, 50, 50), "хранилище 50/50 -- ничего не начислено");
Assert.AreEqual(2, r.Left("q1"), "2 ждут места");
Assert.AreEqual(2, r.Claim("q1", 0, 40, 50), "место появилось -- выданы ждущие");
Assert.AreEqual(2, q.Used(T0 + 10), "квота потрачена один раз: 2, а не 4");
}
}
// Редкий PvE-торговец: синий кристалл за золото (регламент валют 05.11, разд. 3, 10, 12)
public class CrystalTraderTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static System.Func<int, bool> Wallet(float[] gold) => price =>
{
if (gold[0] < price) return false;
gold[0] -= price;
return true;
};
[Test]
public void Trader_Quota4of6_Buys_ChargesGold_CountsQuota_Max2()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 4, T0);
var t = new CrystalTrader(new[] { 30000, 60000 }, 7 * Day);
var gold = new[] { 100000f };
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "первая -- 30 000");
Assert.AreEqual(5, quota.Used(T0 + 1), "квота +1");
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b2", T0 + 2, quota, 10, Wallet(gold)));
Assert.AreEqual(10000f, gold[0], 0.01, "вторая -- 60 000");
Assert.AreEqual(CrystalTrader.Result.NoSlots, t.TryBuy("b3", T0 + 3, quota, 10, Wallet(gold)), "третьей нет");
Assert.AreEqual(10000f, gold[0], 0.01, "золото не списано");
}
[Test]
public void Trader_Quota6of6_Blocked_NoGoldSpent()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 6, T0);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoQuota, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(50000f, gold[0], 0.01, "6/6 + покупка -> блок без расхода золота");
Assert.AreEqual(0, t.BoughtInWindow(T0 + 1));
}
[Test]
public void Trader_NoSpace_Or_NoGold_NothingChanges()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoSpace, t.TryBuy("b1", T0, quota, 0, Wallet(gold)), "хранилище полное");
Assert.AreEqual(50000f, gold[0], 0.01);
var poor = new[] { 1000f };
Assert.AreEqual(CrystalTrader.Result.NoGold, t.TryBuy("b2", T0, quota, 10, Wallet(poor)), "не хватает золота");
Assert.AreEqual(0, quota.Used(T0), "квота не тронута");
Assert.AreEqual(30000, t.PriceNow(T0), "цена по-прежнему первая");
}
[Test]
public void Trader_SameOperationTwice_OnePurchase()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 100000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
Assert.AreEqual(CrystalTrader.Result.Repeated, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "повтор той же покупки -- ничего не списано");
Assert.AreEqual(1, quota.Used(T0 + 1));
}
[Test]
public void Trader_WindowRolls_AndSurvivesSaveLoad()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 1000000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
t.TryBuy("b2", T0 + Day, quota, 10, Wallet(gold));
var copy = new CrystalTrader(null, 7 * Day);
copy.Load(t.ToLines());
Assert.AreEqual(-1, copy.PriceNow(T0 + 2 * Day), "после перезапуска -- те же 2 покупки");
Assert.AreEqual(T0 + 7 * Day, copy.NextSlotMs(T0 + 2 * Day));
Assert.AreEqual(60000, copy.PriceNow(T0 + 7 * Day), "первая покупка вышла из окна -- следующая снова вторая по счёту");
Assert.AreEqual(30000, copy.PriceNow(T0 + 8 * Day), "обе вышли из окна -- снова 30 000");
}
}
// Кристальная шахта: 3 синих кристалла за 7 реальных суток (регламент кристаллов 05.11, разд. 2.1 и 6; ответ 06.11, п. 3)
public class CrystalMineTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static readonly long[] OneMine = { long.MinValue };
static CrystalMine Started()
{
var m = new CrystalMine(3, 7 * Day);
m.Advance(T0, OneMine, 50); // первый расчёт -- точка отсчёта
return m;
}
[Test]
public void Mine_7RealDays_FreeStorage_Exactly3()
{
var m = Started();
int got = 0;
for (long t = T0; t < T0 + 7 * Day; t += 10 * 60 * 1000) got += m.Advance(t + 10 * 60 * 1000, OneMine, 50 - got);
Assert.AreEqual(3, got, "за 7 реальных суток -- ровно 3");
}
[Test]
public void Mine_5RealMinutes_NotWeeklyAmount()
{
var m = Started();
Assert.AreEqual(0, m.Advance(T0 + 5 * 60 * 1000, OneMine, 50), "5 реальных минут -- не недельный объём (×12 не действует)");
}
[Test]
public void Mine_49of50_AtMostOne_50of50_Paused_ThenContinues()
{
var m = Started();
Assert.AreEqual(1, m.Advance(T0 + 7 * Day, OneMine, 1), "49/50 -- не больше 1");
Assert.AreEqual(0, m.ProgressMs, "лишнее не копится: шахта встала после последнего поместившегося");
Assert.AreEqual(0, m.Advance(T0 + 14 * Day, OneMine, 0), "50/50 -- пауза");
Assert.AreEqual(0, m.ProgressMs, "за паузу прогресс не вырос");
Assert.AreEqual(1, m.Advance(T0 + 14 * Day + m.MsPerCrystal, OneMine, 10), "место есть -- дальше с оставшегося прогресса, без начисления за паузу");
}
[Test]
public void Mine_Reload_And_ClockBack_NoDouble()
{
var m = Started();
Assert.AreEqual(1, m.Advance(T0 + m.MsPerCrystal, OneMine, 50));
var copy = new CrystalMine(3, 7 * Day);
copy.Load(m.ToLine());
Assert.AreEqual(0, copy.Advance(T0 + m.MsPerCrystal, OneMine, 50), "после загрузки то же время -- ничего");
Assert.AreEqual(0, copy.Advance(T0, OneMine, 50), "часы назад (сброс перемотки) -- ничего");
Assert.AreEqual(T0 + m.MsPerCrystal, copy.LastMs, "метка не ушла назад");
}
[Test]
public void Mine_TwoMines_Double_BuiltOffline_CountsFromCompletion()
{
var m = Started();
Assert.AreEqual(6, m.Advance(T0 + 7 * Day, new[] { long.MinValue, long.MinValue }, 50), "две шахты -- 6 за 7 суток");
var late = Started();
Assert.AreEqual(1, late.Advance(T0 + 3 * Day, new[] { T0 + 3 * Day - late.MsPerCrystal }, 50),
"шахта достроена офлайн -- добывает с конца стройки, а не с начала офлайна");
Assert.AreEqual(0, Started().Advance(T0 + Day, new long[0], 50), "шахт нет -- ничего");
}
}