Хранилище синих кристаллов: платное расширение 50 -> 75/100/150/200 на Ратуше 3/5/7/9 (регламент валют, разд. 8)

- CrystalStorage (без Unity) + 5 тестов из раздела 12
- Ступени и цены -- EconomyConfig.crystalExpansions (ТЕСТ), купленная ступень -- в секции economy
- Окно Ратуши: «Хранилище синих кристаллов», «Расширить до N»; цена списывается один раз

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-06 16:13:43 +01:00
co-authored by Claude Opus 5.5
parent 98cbe85481
commit a2d85f56c3
7 changed files with 277 additions and 5 deletions
+41
View File
@@ -282,6 +282,8 @@ public static class BuildingWindow
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
}
CrystalStorageBlock(card, level);
if (level >= townHall.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
@@ -323,6 +325,45 @@ public static class BuildingWindow
LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade());
}
// Хранилище синих кристаллов (регламент валют 05.11, разд. 8; ответ 06.11, п. 1): вместимость и платное
// расширение. Ратуша только разрешает ступень -- игрок покупает её здесь за золото и обычные ресурсы
static void CrystalStorageBlock(RectTransform card, int townHallLevel)
{
var storage = EconomyConfig.CrystalStorageNow();
Header(card, "Хранилище синих кристаллов");
Live(card, () =>
{
float have = GameServices.TryGet(out IEconomy e) ? e.Get(ResourceType.Gems) : 0f;
return $"Кристаллов {have:0} / {EconomyConfig.CrystalStorageNow().Capacity}";
}, 24, UIKit.Theme.Text);
var next = storage.Next;
string why = storage.WhyNot(townHallLevel);
if (next == null)
{
UIKit.Text(card, "Хранилище расширено до предела", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
return;
}
var cost = next.Cost ?? new ResourceAmount[0];
if (why != null)
{
UIKit.Text(card, why + ". Цена: " + CostText(cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
return;
}
LiveCost(card, cost);
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
var b = UIKit.Button(card, $"Расширить до {next.Capacity}", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (EconomyConfig.TryExpandCrystalStorage(townHallLevel, out string error))
{
UIKit.Toast($"Хранилище кристаллов: {EconomyConfig.CrystalStorageNow().Capacity}");
Rebuild();
}
else UIKit.Toast(error);
}, -1, 80, 26);
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
}
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4"
static string MoreBuildingsAt(int level, int next)
{
+65 -2
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System;
using UnityEngine;
@@ -70,6 +71,20 @@ public class EconomyConfig : ScriptableObject
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
}
[Serializable]
public class CrystalExpansion
{
public int townHallLevel = 3;
public int capacity = 75;
public float gold, wood, stone, iron;
public CrystalExpansion() { }
public CrystalExpansion(int townHall, int cap, float gold, float wood, float stone, float iron)
{
townHallLevel = townHall; capacity = cap; this.gold = gold; this.wood = wood; this.stone = stone; this.iron = iron;
}
}
[Serializable]
public class StorageSettings // этап 1: лимиты хранения
{
@@ -81,8 +96,17 @@ public class EconomyConfig : ScriptableObject
public float perWarehouseLevel = 1000f;
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
public float perGranaryLevel = 1500f;
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
[Tooltip("Стартовая вместимость синих кристаллов (регламент валют 05.11, разд. 8). ТЕСТ")]
public float crystalCap = 50f;
[Tooltip("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " +
"Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")]
public CrystalExpansion[] crystalExpansions =
{
new CrystalExpansion(3, 75, 2000, 1500, 1500, 0),
new CrystalExpansion(5, 100, 4000, 3000, 3000, 1000),
new CrystalExpansion(7, 150, 7000, 5000, 5000, 2000),
new CrystalExpansion(9, 200, 10000, 8000, 8000, 4000),
};
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
public float warehouseWood = 200f;
@@ -253,7 +277,7 @@ public class EconomyConfig : ScriptableObject
case ResourceType.Iron:
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return s.crystalCap;
case ResourceType.Gems: return CrystalStorageNow().Capacity; // стартовая + купленные расширения
default: return float.PositiveInfinity;
}
}
@@ -326,4 +350,43 @@ public class EconomyConfig : ScriptableObject
default: return 0f;
}
}
// ---------------- Хранилище синих кристаллов (регламент валют 05.11, разд. 8; правила -- CrystalStorage) ----------------
// Правила с купленной ступенью (её хранит Экономика: ResourceManager.CrystalStorageLevel, секция "economy")
public static CrystalStorage CrystalStorageNow()
{
var c = Load();
var tiers = new List<CrystalStorage.Tier>();
if (c.storage.crystalExpansions != null)
foreach (var e in c.storage.crystalExpansions)
{
if (e == null) continue;
var cost = new List<ResourceAmount>();
if (e.gold > 0) cost.Add(new ResourceAmount(ResourceType.Gold, e.gold));
if (e.wood > 0) cost.Add(new ResourceAmount(ResourceType.Wood, e.wood));
if (e.stone > 0) cost.Add(new ResourceAmount(ResourceType.Stone, e.stone));
if (e.iron > 0) cost.Add(new ResourceAmount(ResourceType.Iron, e.iron));
tiers.Add(new CrystalStorage.Tier { TownHallLevel = e.townHallLevel, Capacity = e.capacity, Cost = cost.ToArray() });
}
var storage = new CrystalStorage(Mathf.RoundToInt(c.storage.crystalCap), tiers);
if (ResourceManager.Instance != null) storage.SetLevel(ResourceManager.Instance.CrystalStorageLevel);
return storage;
}
// Купить следующую ступень: цена списывается один раз (всё или ничего), ступень и ресурсы -- в одной секции
// сохранения "economy", сохраняется сразу. error -- почему нельзя
public static bool TryExpandCrystalStorage(int townHallLevel, out string error)
{
var rm = ResourceManager.Instance;
var storage = CrystalStorageNow();
error = storage.WhyNot(townHallLevel);
if (error != null) return false;
if (rm == null || !GameServices.TryGet(out IEconomy economy)) { error = "Склад не найден"; return false; }
if (!storage.TryExpand(townHallLevel, cost => economy.TrySpend(cost))) { error = "Не хватает ресурсов"; return false; }
rm.CrystalStorageLevel = storage.Level;
rm.SetCapacity(ResourceType.Gems, storage.Capacity);
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
return true;
}
}
@@ -30,6 +30,10 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
// запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют.
private Dictionary<ResourceType, float> capacities = new Dictionary<ResourceType, float>();
// Сколько платных расширений хранилища синих кристаллов куплено (регламент валют 05.11, разд. 8).
// Вместимость по ней считает слой приложения (EconomyConfig.CrystalStorageNow); здесь -- только хранится
public int CrystalStorageLevel { get; set; }
// Лимит изменился (для интерфейса: "запас / лимит")
public event Action<ResourceType, float> OnCapacityChanged;
@@ -189,13 +193,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
public List<ResourceSaveEntry> capacities = new List<ResourceSaveEntry>(); // лимиты склада (этап 1)
public int crystalStorageLevel; // купленные расширения хранилища кристаллов (06.11); нет в старом файле -- 0
}
public string SaveKey => "economy";
public string Capture()
{
var data = new EconomySaveData();
var data = new EconomySaveData { crystalStorageLevel = CrystalStorageLevel };
foreach (var kvp in resources)
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
foreach (var kvp in capacities)
@@ -207,6 +212,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
var data = JsonUtility.FromJson<EconomySaveData>(json);
if (data?.resources == null) return;
CrystalStorageLevel = Mathf.Max(0, data.crystalStorageLevel);
if (data.capacities != null)
foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount);
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
@@ -215,6 +221,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
// Новая игра -- стартовые значения из Inspector
public void ResetState()
{
CrystalStorageLevel = 0;
SetStartValues(notify: true);
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
// ХРАНИЛИЩЕ СИНИХ КРИСТАЛЛОВ: вместимость и платное расширение
// (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 8; «Ответ Володе» от 06.11.2026, п. 1).
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Старт -- BaseCapacity (ТЕСТ 50). Ступени (ТЕСТ): Ратуша 3 -- 75, 5 -- 100, 7 -- 150, 9 -- 200.
// - Ступень НЕ открывается сама с уровнем Ратуши: Ратуша только разрешает её, игрок покупает за золото и
// обычные ресурсы. Ступени -- по порядку, одна покупка -- одна ступень.
// - Цена списывается один раз: ступень растёт, только если списание прошло (всё или ничего). Повторное
// нажатие без следующей ступени или без нужной Ратуши ничего не списывает.
// - Сохраняется номер купленной ступени (Level), вместимость по нему считается заново -- числа можно менять.
// Без Unity: те же правила будут на сервере и в тестах. Числа и цены даёт слой приложения (EconomyConfig).
public sealed class CrystalStorage
{
public sealed class Tier
{
public int TownHallLevel; // с какого уровня Ратуши можно купить
public int Capacity; // вместимость после покупки
public ResourceAmount[] Cost; // цена: золото и обычные ресурсы
}
public readonly int BaseCapacity;
readonly List<Tier> tiers;
public CrystalStorage(int baseCapacity, IEnumerable<Tier> tiers)
{
BaseCapacity = Math.Max(0, baseCapacity);
this.tiers = new List<Tier>();
if (tiers != null) foreach (var t in tiers) if (t != null) this.tiers.Add(t);
}
// Сколько ступеней куплено (0 -- только стартовая вместимость)
public int Level { get; private set; }
public int TierCount => tiers.Count;
public int Capacity => Level <= 0 ? BaseCapacity : Math.Max(BaseCapacity, tiers[Level - 1].Capacity);
// Следующая ступень; null -- все куплены
public Tier Next => Level < tiers.Count ? tiers[Level] : null;
// Восстановить из сохранения (номер ступени; лишнее обрезается, если ступеней стало меньше)
public void SetLevel(int level) => Level = Math.Max(0, Math.Min(level, tiers.Count));
// Почему сейчас нельзя расширить: null -- можно (если хватит ресурсов -- это проверяет списание)
public string WhyNot(int townHallLevel)
{
var next = Next;
if (next == null) return "Хранилище расширено до предела";
if (townHallLevel < next.TownHallLevel) return $"Расширение до {next.Capacity} откроется на Ратуше {next.TownHallLevel} ур.";
return null;
}
// Купить следующую ступень. trySpend -- списание цены «всё или ничего» (Экономика). true -- ступень куплена
public bool TryExpand(int townHallLevel, Func<IReadOnlyList<ResourceAmount>, bool> trySpend)
{
if (WhyNot(townHallLevel) != null || trySpend == null) return false;
if (!trySpend(Next.Cost ?? new ResourceAmount[0])) return false;
Level++;
return true;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b8d2f6a1c9e47d3b5a0e7f3c2d19a85
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -429,3 +429,88 @@ public class CrystalRewardsTests
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"), "больше ничего не ждёт -- выдан ровно один раз");
}
}