Feature/blue crystals #19
@@ -204,10 +204,23 @@ public static class BuildingWindow
|
||||
|
||||
// ---------------- Кристалл ----------------
|
||||
|
||||
// Кристальная шахта (ответ 06.11, п. 3): 3 синих кристалла за 7 реальных суток, при полном хранилище -- пауза
|
||||
static void CrystalSection(RectTransform card)
|
||||
{
|
||||
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
var gp = GameProgress.Instance;
|
||||
if (gp != null)
|
||||
UIKit.Text(card, $"Кристальная шахта: {gp.CrystalMinePerPeriod} синих кристалла за {gp.CrystalMinePeriodDays} реальных суток, " +
|
||||
"и пока игра закрыта. Не входит в недельный предел. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
|
||||
if (gp == null) return;
|
||||
Live(card, () =>
|
||||
{
|
||||
int working = gp.CrystalMinesWorking;
|
||||
if (working == 0) return "Шахта ещё не работает";
|
||||
if (CrystalRewards.Free(GameProgress.GemsHave, GameProgress.GemsCap) <= 0)
|
||||
return "Пауза: хранилище кристаллов заполнено. Освободите место или расширьте его в окне Ратуши";
|
||||
return $"Работает шахт: {working}. Следующий кристалл через {GameData.FormatTime(gp.CrystalMineSecondsToNext)}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
}
|
||||
|
||||
// ---------------- Склад и Амбар ----------------
|
||||
@@ -282,6 +295,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 +338,54 @@ 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);
|
||||
// Недельный PvE-предел (регламент валют 05.11, ответ 06.11): сколько новых кристаллов из заданий, событий и боёв
|
||||
Live(card, () =>
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
if (gp == null) return "";
|
||||
string text = $"Новых кристаллов из заданий, событий и боёв за 7 дней: {gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit}";
|
||||
if (gp.CrystalQuotaLeft <= 0) text += $". Квота освободится через {GameData.FormatTime(gp.CrystalQuotaSecondsToFree)}, до тех пор -- золото";
|
||||
return text;
|
||||
}, 22, UIKit.Theme.TextDim);
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -600,21 +663,21 @@ public static class BuildingWindow
|
||||
live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); });
|
||||
Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text);
|
||||
|
||||
int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem);
|
||||
Func<int> gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0;
|
||||
// Ускорение -- за золото (06.11, ТЕСТ: EconomyConfig.construction.secondsPerGold)
|
||||
Func<int> gold = () => t != null ? EconomyConfig.SpeedUpGold(t.SecondsLeft) : 0;
|
||||
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
if (t == null) return;
|
||||
int price = gems();
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price))
|
||||
int price = gold();
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gold, price))
|
||||
{
|
||||
UIKit.Toast($"Нужно {price} кристаллов");
|
||||
UIKit.Toast($"Нужно {price} золота");
|
||||
return;
|
||||
}
|
||||
t.Complete(); // окно перестроит OnBuildCompleted
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); });
|
||||
UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true);
|
||||
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true); });
|
||||
UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true);
|
||||
}
|
||||
|
||||
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
|
||||
|
||||
@@ -77,6 +77,21 @@ public class DebugPanel : MonoBehaviour
|
||||
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
|
||||
Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems);
|
||||
|
||||
// Недельный PvE-предел кристаллов: «+1 PvE-кристалл» выдаёт как настоящая награда (квота, место, компенсация)
|
||||
var rowQ = UIKit.Row(card, 70, 10);
|
||||
Btn(rowQ, "+1 PvE-кристалл", UIKit.Theme.Blue, () => { if (GameProgress.Instance != null) GameProgress.Instance.Grant(new[] { new RewardItem(ResourceType.Gems, 1) }); Refresh(); });
|
||||
// Только для проверки торговца (30 000 / 60 000 золота) и Кристальной шахты (12 000 золота, 3200 камня...):
|
||||
// обычные ресурсы ставятся напрямую, мимо лимита склада
|
||||
Btn(rowQ, "Ресурсы 100 000", UIKit.Theme.Green, () =>
|
||||
{
|
||||
var rm = ResourceManager.Instance;
|
||||
if (rm != null)
|
||||
foreach (var t in new[] { ResourceType.Gold, ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Food })
|
||||
rm.SetResource(t, 100000);
|
||||
Refresh();
|
||||
});
|
||||
Btn(rowQ, "Сбросить квоту", UIKit.Theme.Gray, () => { if (GameProgress.Instance != null) GameProgress.Instance.DevResetCrystalQuota(); UIKit.Toast("Недельная квота кристаллов сброшена"); Refresh(); });
|
||||
|
||||
var row3 = UIKit.Row(card, 70, 10);
|
||||
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
|
||||
Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); });
|
||||
|
||||
@@ -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,31 @@ 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("Недельный PvE-предел: не больше стольких НОВЫХ синих кристаллов за окно из заданий, событий, боёв, PvE-торговца. " +
|
||||
"Шахта, торговля и грабёж -- вне предела (регламент валют 05.11, разд. 5.2; ответ 06.11, п. 2). ТЕСТ")]
|
||||
public int crystalPveLimit = 6;
|
||||
[Tooltip("Окно недельного PvE-предела, реальных суток (скользящее). ТЕСТ")]
|
||||
public int crystalPveWindowDays = 7;
|
||||
[Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")]
|
||||
public float crystalCompensationGold = 1000f;
|
||||
[Tooltip("Кристальная шахта: синих кристаллов за период на одну работающую шахту (ответ 06.11, п. 3). ТЕСТ")]
|
||||
public int crystalMinePerPeriod = 3;
|
||||
[Tooltip("Кристальная шахта: период, РЕАЛЬНЫХ суток (не игровые часы ×12). ТЕСТ")]
|
||||
public int crystalMinePeriodDays = 7;
|
||||
[Tooltip("Редкий PvE-торговец: цена синего кристалла в золоте по порядку покупок за 7 реальных суток; покупок не больше, " +
|
||||
"чем цен (регламент валют 05.11, разд. 3). Входит в недельный PvE-предел. ТЕСТ")]
|
||||
public int[] crystalTraderPrices = { 30000, 60000 };
|
||||
[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;
|
||||
@@ -137,8 +175,11 @@ public class EconomyConfig : ScriptableObject
|
||||
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
|
||||
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
|
||||
public float townHallTimeMultiplier = 2f;
|
||||
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
|
||||
[Tooltip("Устарело (06.11): ускорение теперь за золото -- secondsPerGold")]
|
||||
public int secondsPerGem = 30;
|
||||
[Tooltip("Ускорение стройки, улучшения и обучения войск -- за золото (06.11; регламент валют: золото -- основной ресурс " +
|
||||
"построек и улучшений, синие кристаллы на ускорения не тратятся): секунд за 1 золото. ТЕСТ")]
|
||||
public int secondsPerGold = 10;
|
||||
// Сноса зданий нет (решение 04.11): только бесплатный перенос
|
||||
}
|
||||
|
||||
@@ -219,7 +260,7 @@ public class EconomyConfig : ScriptableObject
|
||||
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
|
||||
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 2, 2), // Кристальная шахта: вторая -- на Ратуше 9 (ответ 06.11, п. 3; уровень ТЕСТ)
|
||||
};
|
||||
|
||||
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
|
||||
@@ -253,7 +294,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 +367,50 @@ public class EconomyConfig : ScriptableObject
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена ускорения в золоте: сколько осталось секунд -> золото (не меньше 1). Стройка, улучшение и обучение войск
|
||||
public static int SpeedUpGold(double secondsLeft)
|
||||
{
|
||||
int perGold = Mathf.Max(1, Load().construction.secondsPerGold);
|
||||
return Mathf.Max(1, Mathf.CeilToInt((float)(secondsLeft / perGold)));
|
||||
}
|
||||
|
||||
// ---------------- Хранилище синих кристаллов (регламент валют 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +87,13 @@ public class City3DCamera : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
public void FocusOn(Rect area)
|
||||
// onlyZoomOut -- не приближать: если камера уже дальше, расстояние остаётся (открытие зоны, 06.11)
|
||||
public void FocusOn(Rect area, bool onlyZoomOut = false)
|
||||
{
|
||||
focusTarget = new Vector3(area.center.x, 0, area.center.y);
|
||||
// отойти так, чтобы область поместилась между HUD сверху и снизу
|
||||
focusDistance = Mathf.Clamp(FitDistance(area), minDistance, MaxDistanceNow);
|
||||
if (onlyZoomOut) focusDistance = Mathf.Max(focusDistance, Mathf.Min(distance, MaxDistanceNow));
|
||||
focusing = true;
|
||||
zoomTo = -1f;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
|
||||
}
|
||||
|
||||
bool stateApplied; // сохранённый город уже поставлен (в первом кадре, после загрузки сохранения в Start)
|
||||
public bool StateApplied => stateApplied;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
@@ -982,14 +983,18 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
|
||||
if (ghost != null) UpdateGhost();
|
||||
if (townHallLevel > before)
|
||||
{
|
||||
// Открылась зона -- камера плавно отходит и показывает её; здания остаются на местах
|
||||
int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2;
|
||||
cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2));
|
||||
// Открылась зона -- камера плавно отходит и показывает её КРАЙ: старая зона и ZoneEdgeCells клеток новой за её
|
||||
// границей (06.11, Саша: вся зона целиком -- камера улетала слишком далеко). Ближе, чем была, не подъезжает
|
||||
int c = City3DLayout.Size / 2;
|
||||
int show = Mathf.Min(City3DLayout.ZoneHalfSize(townHallLevel), City3DLayout.ZoneHalfSize(before) + ZoneEdgeCells);
|
||||
cameraRig.FocusOn(new Rect(c - show, c - show, show * 2, show * 2), onlyZoomOut: true);
|
||||
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
|
||||
if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
|
||||
}
|
||||
}
|
||||
|
||||
const int ZoneEdgeCells = 2; // сколько клеток новой зоны показать при её открытии
|
||||
|
||||
// Камера: центр кадра -- только над открытой зоной, при самом дальнем отдалении видна вся зона (06.11)
|
||||
void UpdateCameraArea()
|
||||
{
|
||||
|
||||
@@ -116,6 +116,35 @@ public static class City3DGameplay
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// Кристальные шахты: с какого момента каждая добывает (мс; long.MinValue -- давно достроена; конец стройки -- если
|
||||
// ещё строится или достроилась офлайн). Город открыт -- по сцене, иначе -- по сохранению settlement3d.
|
||||
// null -- город ещё не загружен: считать рано (иначе офлайн-время шахты пропало бы)
|
||||
public static List<long> CrystalMinesActiveFrom()
|
||||
{
|
||||
var list = new List<long>();
|
||||
var city = City3DController.Instance;
|
||||
if (city != null)
|
||||
{
|
||||
if (!city.StateApplied) return null;
|
||||
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (id.buildingName != "Кристалл") continue;
|
||||
var t = BuildTimer.Of(id.gameObject);
|
||||
list.Add(t != null && BuildTimer.IsConstructing(id.transform) ? t.endMs : long.MinValue);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if (!City3DSave.Loaded) return null;
|
||||
if (City3DSave.Cached != null)
|
||||
foreach (var e in City3DSave.Cached.buildings)
|
||||
{
|
||||
if (e == null || e.type != "Кристалл") continue;
|
||||
bool building = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1 && e.timerEndMs > 0;
|
||||
list.Add(building ? e.timerEndMs : long.MinValue);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static int CountBuilt(string gameName)
|
||||
{
|
||||
int n = 0;
|
||||
|
||||
@@ -39,6 +39,8 @@ public sealed class City3DSave : ISaveParticipant
|
||||
|
||||
public static readonly City3DSave Instance = new City3DSave();
|
||||
public static Data Cached { get; private set; }
|
||||
// Сохранение уже прочитано (или новая игра): по Cached можно считать здания вне 3D-сцены (Кристальная шахта на карте мира)
|
||||
public static bool Loaded { get; private set; }
|
||||
public static ILive Live;
|
||||
|
||||
public string SaveKey => "settlement3d";
|
||||
@@ -51,6 +53,7 @@ public sealed class City3DSave : ISaveParticipant
|
||||
|
||||
public void Restore(string json)
|
||||
{
|
||||
Loaded = true;
|
||||
Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson<Data>(json);
|
||||
if (Cached != null)
|
||||
{
|
||||
@@ -63,6 +66,7 @@ public sealed class City3DSave : ISaveParticipant
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
Loaded = true;
|
||||
Cached = null;
|
||||
Live?.OnSaveLoaded();
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ public class GameProgress : MonoBehaviour
|
||||
nextTick = Time.unscaledTime + 1f;
|
||||
EnsureDailyAndWeekly();
|
||||
ProcessTraining();
|
||||
ProcessCrystalMine();
|
||||
UpdateBuildingsCount();
|
||||
UpdatePowerResource();
|
||||
}
|
||||
@@ -146,6 +147,16 @@ public class GameProgress : MonoBehaviour
|
||||
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
|
||||
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
|
||||
crystals.Load(Data.crystalWaiting);
|
||||
if (Data.crystalQuota == null) Data.crystalQuota = new List<string>();
|
||||
var storageCfg = EconomyConfig.Load().storage;
|
||||
quota = new CrystalQuota(storageCfg.crystalPveLimit, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
|
||||
quota.Load(Data.crystalQuota);
|
||||
if (Data.crystalTrader == null) Data.crystalTrader = new List<string>();
|
||||
if (Data.crystalTraderNextId < 1) Data.crystalTraderNextId = 1;
|
||||
trader = new CrystalTrader(storageCfg.crystalTraderPrices, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
|
||||
trader.Load(Data.crystalTrader);
|
||||
mine = new CrystalMine(storageCfg.crystalMinePerPeriod, Math.Max(1, storageCfg.crystalMinePeriodDays) * CrystalQuota.Day);
|
||||
mine.Load(Data.crystalMine);
|
||||
if (Data.mail == null) Data.mail = new List<MailItem>();
|
||||
if (Data.shopIds == null) Data.shopIds = new List<string>();
|
||||
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
|
||||
@@ -309,13 +320,24 @@ public class GameProgress : MonoBehaviour
|
||||
return true;
|
||||
}
|
||||
|
||||
// Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят
|
||||
// письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется
|
||||
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
|
||||
// Выдать награду. Синие кристаллы (регламент валют 05.11, ответ 06.11):
|
||||
// - новые PvE-кристаллы (newPveCrystals) -- в пределах недельной квоты; сверх неё -- золото (компенсация);
|
||||
// - не сверх вместимости хранилища: не поместившиеся приходят письмом «Кристаллы ждут места» и забираются из него,
|
||||
// когда место появится (квота за них уже учтена). Ничего не теряется.
|
||||
// Возвращает, что выдано сейчас (кристаллы -- сколько поместилось, плюс компенсация)
|
||||
public RewardItem[] Grant(RewardItem[] rewards, int xp = 0, bool toast = true, bool newPveCrystals = true)
|
||||
{
|
||||
int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
int gems = GemsIn(rewards), compensated = 0, compGold = 0;
|
||||
if (gems > 0 && newPveCrystals)
|
||||
{
|
||||
int allowed = TakeQuota(null, gems, out compGold);
|
||||
compensated = gems - allowed;
|
||||
gems = allowed;
|
||||
}
|
||||
int fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
var given = new List<RewardItem>(WithoutGems(rewards));
|
||||
if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit));
|
||||
if (compGold > 0) given.Add(new RewardItem(ResourceType.Gold, compGold));
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
@@ -332,11 +354,136 @@ public class GameProgress : MonoBehaviour
|
||||
string text = GameData.RewardsText(given.ToArray());
|
||||
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
|
||||
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
|
||||
if (compensated > 0) text += (text.Length > 0 ? ". " : "") + QuotaText(compensated, compGold);
|
||||
if (text.Length > 0) UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
MarkChanged();
|
||||
return given.ToArray();
|
||||
}
|
||||
|
||||
// ---------------- Недельный PvE-предел синих кристаллов (правила -- CrystalQuota) ----------------
|
||||
|
||||
CrystalQuota quota = new CrystalQuota();
|
||||
|
||||
public int CrystalQuotaUsed => quota.Used(GameClock.UnixNowMs);
|
||||
public int CrystalQuotaLimit => quota.Limit;
|
||||
public int CrystalQuotaLeft => quota.Left(GameClock.UnixNowMs);
|
||||
// Через сколько секунд освободится квота (0 -- уже есть)
|
||||
public double CrystalQuotaSecondsToFree
|
||||
{
|
||||
get { long at = quota.NextFreeMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
|
||||
}
|
||||
|
||||
// Взять квоту: сколько из amount выдать кристаллами; за остальные -- золото (compGold). id -- операция (null -- разовая)
|
||||
int TakeQuota(string id, int amount, out int compGold)
|
||||
{
|
||||
int allowed = quota.Take(id, amount, GameClock.UnixNowMs);
|
||||
compGold = Mathf.RoundToInt((amount - allowed) * EconomyConfig.Load().storage.crystalCompensationGold);
|
||||
Data.crystalQuota = quota.ToLines();
|
||||
MarkChanged();
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// ---------------- Кристальная шахта (правила -- CrystalMine) ----------------
|
||||
|
||||
CrystalMine mine = new CrystalMine();
|
||||
|
||||
// Работающих шахт сейчас (достроенных)
|
||||
public int CrystalMinesWorking
|
||||
{
|
||||
get
|
||||
{
|
||||
var from = City3DGameplay.CrystalMinesActiveFrom();
|
||||
int n = 0;
|
||||
if (from != null) foreach (long t in from) if (t <= GameClock.UnixNowMs) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
public double CrystalMineSecondsToNext { get { long ms = mine.MsToNext(CrystalMinesWorking); return ms < 0 ? -1 : ms / 1000.0; } }
|
||||
public int CrystalMinePerPeriod => mine.PerPeriod;
|
||||
public int CrystalMinePeriodDays => (int)(mine.PeriodMs / CrystalQuota.Day);
|
||||
|
||||
// Раз в секунду: выработка шахт до текущего момента (и офлайн -- при первом расчёте после входа).
|
||||
// Не входит в недельный PvE-предел. Хранилище полное -- пауза
|
||||
void ProcessCrystalMine()
|
||||
{
|
||||
if (ResourceManager.Instance == null) return; // склада нет -- не считать (время не теряется)
|
||||
var from = City3DGameplay.CrystalMinesActiveFrom();
|
||||
if (from == null) return; // город ещё не загружен
|
||||
int got = mine.Advance(GameClock.UnixNowMs, from, CrystalRewards.Free(GemsHave, GemsCap));
|
||||
Data.crystalMine = mine.ToLine();
|
||||
if (got <= 0) return;
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, got); // место проверено в Advance
|
||||
suppressTracking--;
|
||||
MarkChanged();
|
||||
Save(); // прогресс шахты
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // и кристаллы -- сразу, без повторного начисления
|
||||
UIKit.Toast($"Кристальная шахта: +{got} синих кристаллов");
|
||||
}
|
||||
|
||||
// ---------------- Редкий PvE-торговец (правила -- CrystalTrader) ----------------
|
||||
|
||||
CrystalTrader trader = new CrystalTrader();
|
||||
|
||||
public int TraderPrice => trader.PriceNow(GameClock.UnixNowMs); // -1 -- в этом окне больше нельзя
|
||||
public int TraderBought => trader.BoughtInWindow(GameClock.UnixNowMs);
|
||||
public int TraderMaxPurchases => trader.Prices.Length;
|
||||
public double TraderSecondsToSlot
|
||||
{
|
||||
get { long at = trader.NextSlotMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
|
||||
}
|
||||
|
||||
// Почему сейчас нельзя купить (без списания); null -- можно, если хватит золота
|
||||
public string TraderBlockReason()
|
||||
{
|
||||
switch (trader.Check(GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap)))
|
||||
{
|
||||
case CrystalTrader.Result.NoSlots: return $"Куплено {TraderBought} из {TraderMaxPurchases} за 7 дней. Следующая покупка -- через {GameData.FormatTime(TraderSecondsToSlot)}";
|
||||
case CrystalTrader.Result.NoQuota: return $"Недельный предел новых кристаллов исчерпан ({quota.Limit} за 7 дней). Освободится через {GameData.FormatTime(CrystalQuotaSecondsToFree)}";
|
||||
case CrystalTrader.Result.NoSpace: return $"Хранилище кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). Расширить -- в окне Ратуши";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Купить 1 синий кристалл. Проверки (покупки, квота, место) -- до оплаты: при отказе золото не списывается
|
||||
public void BuyCrystalFromTrader()
|
||||
{
|
||||
string id = "trader_" + Data.crystalTraderNextId;
|
||||
var result = trader.TryBuy(id, GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap),
|
||||
price => GameServices.TryGet(out IEconomy e) && e.TrySpend(ResourceType.Gold, price));
|
||||
if (result != CrystalTrader.Result.Bought)
|
||||
{
|
||||
UIKit.Toast(result == CrystalTrader.Result.NoGold ? $"Нужно {TraderPrice:N0} золота" : (TraderBlockReason() ?? "Покупка недоступна"));
|
||||
return;
|
||||
}
|
||||
Data.crystalTraderNextId++;
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, 1); // место проверено до оплаты
|
||||
suppressTracking--;
|
||||
}
|
||||
Data.crystalTrader = trader.ToLines();
|
||||
Data.crystalQuota = quota.ToLines();
|
||||
MarkChanged();
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // золото, кристалл и покупка -- сразу на диск
|
||||
UIKit.Toast("Куплен 1 синий кристалл");
|
||||
}
|
||||
|
||||
// Только для проверки (DEV): забыть недельную квоту
|
||||
public void DevResetCrystalQuota()
|
||||
{
|
||||
quota.Load(null);
|
||||
trader.Load(null);
|
||||
Data.crystalQuota = quota.ToLines();
|
||||
Data.crystalTrader = trader.ToLines();
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
string QuotaText(int compensated, int compGold) =>
|
||||
$"Недельный предел синих кристаллов ({quota.Limit} за {quota.WindowMs / CrystalQuota.Day} дн.) исчерпан: вместо {compensated} кристаллов -- {compGold} золота";
|
||||
|
||||
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
|
||||
|
||||
readonly CrystalRewards crystals = new CrystalRewards();
|
||||
@@ -526,7 +673,7 @@ public class GameProgress : MonoBehaviour
|
||||
if (!IsQuestDone(q, daily)) return;
|
||||
|
||||
int gems = GemsIn(q.rewards);
|
||||
if (!crystals.CanStart(key, gems, GemsHave, GemsCap))
|
||||
if (!crystals.CanStart(key, Mathf.Min(gems, quota.Left(GameClock.UnixNowMs)), GemsHave, GemsCap))
|
||||
{
|
||||
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
|
||||
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
|
||||
@@ -536,14 +683,18 @@ public class GameProgress : MonoBehaviour
|
||||
Data.questsDone++;
|
||||
AddEventPoints(20);
|
||||
|
||||
var rest = WithoutGems(q.rewards);
|
||||
Grant(rest, q.xp, false);
|
||||
int given = gems > 0 ? GiveQuestGems(key, gems) : 0, left = crystals.Left(key);
|
||||
// Квота -- один раз на задание (id); сверх неё -- золото. Ждущие места кристаллы квоту второй раз не берут
|
||||
int compGold = 0, allowed = gems > 0 ? TakeQuota(key, gems, out compGold) : 0;
|
||||
var rest = new List<RewardItem>(WithoutGems(q.rewards));
|
||||
if (compGold > 0) rest.Add(new RewardItem(ResourceType.Gold, compGold));
|
||||
Grant(rest.ToArray(), q.xp, false, false);
|
||||
int given = allowed > 0 ? GiveQuestGems(key, allowed) : 0, left = crystals.Left(key);
|
||||
var shown = new List<RewardItem>(rest);
|
||||
if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given));
|
||||
string text = GameData.RewardsText(shown.ToArray());
|
||||
if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта";
|
||||
if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях";
|
||||
if (gems - allowed > 0) text += ". " + QuotaText(gems - allowed, compGold);
|
||||
UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
|
||||
@@ -632,7 +783,7 @@ public class GameProgress : MonoBehaviour
|
||||
m.rewards.Clear();
|
||||
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
|
||||
else m.claimed = true;
|
||||
Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит
|
||||
Grant(give.ToArray(), 0, toast, false); // всё помещается; квота за них уже учтена при первой выдаче
|
||||
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
|
||||
return left == 0;
|
||||
}
|
||||
@@ -787,7 +938,7 @@ public class GameProgress : MonoBehaviour
|
||||
// Заказ не встал -- цену возвращаем целиком
|
||||
var refund = new RewardItem[def.cost.Length];
|
||||
for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count);
|
||||
Grant(refund, 0, false);
|
||||
Grant(refund, 0, false, false); // возврат -- не новые кристаллы
|
||||
UIKit.Toast(error);
|
||||
return;
|
||||
}
|
||||
@@ -811,14 +962,14 @@ public class GameProgress : MonoBehaviour
|
||||
var queue = Training;
|
||||
if (queue.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(queue[queue.Count - 1]);
|
||||
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
|
||||
return EconomyConfig.SpeedUpGold(secs); // за золото (06.11, ТЕСТ; было 1 кристалл за 30 с)
|
||||
}
|
||||
}
|
||||
|
||||
public void SpeedUpTraining()
|
||||
{
|
||||
if (Training.Count == 0) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gold, SpeedUpCost) })) return;
|
||||
Army.FinishAllTraining(GameClock.UnixNowMs);
|
||||
}
|
||||
|
||||
@@ -896,7 +1047,7 @@ public class GameProgress : MonoBehaviour
|
||||
Data.battlesWon++;
|
||||
Data.dailyBattles++;
|
||||
AddEventPoints(10);
|
||||
Grant(res.rewards, res.xp, false);
|
||||
res.rewards = Grant(res.rewards, res.xp, false); // в окне итога -- что выдано на самом деле (квота, место)
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -71,6 +71,13 @@ public class ProgressData
|
||||
public List<string> claimedDaily = new List<string>();
|
||||
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
|
||||
public List<string> crystalWaiting = new List<string>();
|
||||
// Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции"
|
||||
public List<string> crystalQuota = new List<string>();
|
||||
// Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки"
|
||||
public List<string> crystalTrader = new List<string>();
|
||||
public int crystalTraderNextId = 1;
|
||||
// Кристальная шахта: "прогресс мс|до какого момента посчитано мс" (ответ 06.11, п. 3)
|
||||
public string crystalMine = "";
|
||||
public float[] dailyCollected = new float[8];
|
||||
public int dailyBattles;
|
||||
public int dailyTroops;
|
||||
|
||||
@@ -16,13 +16,21 @@ public class RewardsPanel : BasePanel
|
||||
|
||||
bool canClaim = GP.CanClaimLogin;
|
||||
int total = GameData.LoginRewards.Length;
|
||||
|
||||
// Кнопка и таймер -- сверху: в горизонтальном экране окно низкое, и под календарём кнопка уходила
|
||||
// за нижнюю панель HUD -- её не было видно (06.11)
|
||||
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
|
||||
() => GP.ClaimLogin(), -1, 90, 32);
|
||||
b.interactable = canClaim;
|
||||
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
|
||||
|
||||
int claimedUpTo = d.loginRewardIndex; // сколько дней цикла уже забрано
|
||||
if (!canClaim && claimedUpTo == 0 && d.lastLoginRewardDay >= 0) claimedUpTo = total; // только что забрали 7-й
|
||||
|
||||
RectTransform row = null;
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
if (i % 4 == 0) row = UIKit.Row(list, 230, 12);
|
||||
if (i % 4 == 0) row = UIKit.Row(list, 170, 12);
|
||||
bool claimed = i < claimedUpTo;
|
||||
bool current = canClaim && i == d.loginRewardIndex;
|
||||
|
||||
@@ -39,12 +47,6 @@ public class RewardsPanel : BasePanel
|
||||
claimed ? UIKit.Theme.Green : UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
UIKit.Spacer(list, 10);
|
||||
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
|
||||
() => GP.ClaimLogin(), -1, 110, 36);
|
||||
b.interactable = canClaim;
|
||||
|
||||
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50);
|
||||
Tick();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
// МАГАЗИН: бесплатный сундук раз в день, пакеты ресурсов за кристаллы, обмен золота,
|
||||
// витрина платных наборов (пока "скоро"). Лимиты покупок сбрасываются в полночь.
|
||||
// МАГАЗИН: редкий PvE-торговец (синий кристалл за золото), бесплатный сундук раз в день, пакеты ресурсов за золото.
|
||||
// Лимиты пакетов сбрасываются в полночь; у торговца -- свои: не больше 2 кристаллов за 7 реальных суток.
|
||||
public class ShopPanel : BasePanel
|
||||
{
|
||||
TextMeshProUGUI timer;
|
||||
@@ -15,6 +15,8 @@ public class ShopPanel : BasePanel
|
||||
WalletItem(wallet, ResourceType.Gems);
|
||||
WalletItem(wallet, ResourceType.Gold);
|
||||
|
||||
TraderCard(list);
|
||||
|
||||
timer = UIKit.Text(list, "", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
|
||||
Tick();
|
||||
|
||||
@@ -52,6 +54,30 @@ public class ShopPanel : BasePanel
|
||||
}
|
||||
}
|
||||
|
||||
// Редкий PvE-торговец (регламент валют 05.11, разд. 3): дорогой обмен золота на синий кристалл
|
||||
void TraderCard(Transform list)
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
int price = gp.TraderPrice;
|
||||
string block = gp.TraderBlockReason();
|
||||
bool enough = price >= 0 && gp.Res(ResourceType.Gold) >= price;
|
||||
|
||||
var card = UIKit.Card(list, UIKit.Theme.CardDark);
|
||||
var row = UIKit.Row(card, 150, 16);
|
||||
UIKit.Icon(row, GameData.ResColor(ResourceType.Gems), "", 110);
|
||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
var title = UIKit.Text(info, "Редкий торговец: синий кристалл", 30);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, $"Куплено за 7 дней: {gp.TraderBought} / {gp.TraderMaxPurchases}. Входит в недельный предел новых кристаллов " +
|
||||
$"({gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit})", 22, UIKit.Theme.TextDim);
|
||||
if (block != null) UIKit.Text(info, block, 22, UIKit.Theme.Gold);
|
||||
|
||||
string label = price < 0 ? "Недоступно" : price.ToString("N0") + " золота";
|
||||
var b = UIKit.Button(row, label, block == null && enough ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { gp.BuyCrystalFromTrader(); RequestRebuild(); }, 260, 100, 24);
|
||||
b.interactable = block == null;
|
||||
}
|
||||
|
||||
static void WalletItem(Transform parent, ResourceType t)
|
||||
{
|
||||
UIKit.Icon(parent, GameData.ResColor(t), "", 40);
|
||||
|
||||
@@ -4,7 +4,7 @@ using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
|
||||
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
|
||||
// ускорение за золото. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
|
||||
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
|
||||
public class TroopsPanel : BasePanel
|
||||
{
|
||||
@@ -293,6 +293,6 @@ public class TroopsPanel : BasePanel
|
||||
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
|
||||
}
|
||||
if (speedUp != null && training.Count > 0)
|
||||
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
|
||||
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " золота", 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,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// КРИСТАЛЬНАЯ ШАХТА: выработка синих кристаллов (Регламент редкости синих кристаллов от 05.11.2026, разд. 2.1;
|
||||
// Регламент валют от 05.11, разд. 5.2 и 8.1; «Ответ Володе» от 06.11, п. 3). Документ: Docs/Modules/Economy.md.
|
||||
//
|
||||
// - Каждая работающая шахта -- PerPeriod (ТЕСТ 3) кристаллов за PeriodMs (7 РЕАЛЬНЫХ суток; не игровые часы ×12),
|
||||
// равномерно: один кристалл за PeriodMs / PerPeriod. Шахты (до двух) складываются.
|
||||
// - Шахта работает с момента, когда достроена (activeFromMs): стройка, закончившаяся офлайн, добывает с её конца.
|
||||
// - Хранилище полное -- пауза: прогресс не растёт, скрытого запаса нет. Хранилище заполнилось в ходе расчёта --
|
||||
// шахта встаёт сразу после последнего поместившегося кристалла (прогресс 0). Освободилось место -- выработка идёт
|
||||
// дальше с оставшегося прогресса, без начисления за время паузы.
|
||||
// - Офлайн: досчёт по меткам при входе; уже посчитанное время второй раз не начисляется; часы назад (сброс
|
||||
// DEV-перемотки) -- метка не уходит назад, ничего не начисляется.
|
||||
// - В недельный PvE-предел не входит (отдельный учёт).
|
||||
// Без Unity: те же правила будут на сервере и в тестах.
|
||||
public sealed class CrystalMine
|
||||
{
|
||||
public readonly int PerPeriod;
|
||||
public readonly long PeriodMs;
|
||||
|
||||
// Прогресс к следующему кристаллу (мс работы одной шахты) и до какого момента посчитано
|
||||
public long ProgressMs { get; private set; }
|
||||
public long LastMs { get; private set; }
|
||||
|
||||
public CrystalMine(int perPeriod = 3, long periodMs = 7 * CrystalQuota.Day)
|
||||
{
|
||||
PerPeriod = Math.Max(1, perPeriod);
|
||||
PeriodMs = Math.Max(1, periodMs);
|
||||
}
|
||||
|
||||
public long MsPerCrystal => Math.Max(1, PeriodMs / PerPeriod);
|
||||
|
||||
// Досчитать до nowMs. mineActiveFromMs -- по шахте: с какого момента она работает (long.MinValue -- давно).
|
||||
// freeSpace -- сколько кристаллов помещается в хранилище. Возвращает, сколько кристаллов начислить
|
||||
public int Advance(long nowMs, IEnumerable<long> mineActiveFromMs, int freeSpace)
|
||||
{
|
||||
if (LastMs == 0) { LastMs = nowMs; return 0; } // первый расчёт: отсюда и считаем
|
||||
if (nowMs <= LastMs) return 0; // часы назад или то же время -- ничего
|
||||
long from = LastMs;
|
||||
LastMs = nowMs;
|
||||
if (freeSpace <= 0) return 0; // пауза: хранилище полное, прогресс не растёт
|
||||
|
||||
long work = 0;
|
||||
if (mineActiveFromMs != null)
|
||||
foreach (long active in mineActiveFromMs)
|
||||
{
|
||||
long start = Math.Max(from, active);
|
||||
if (start < nowMs) work += nowMs - start;
|
||||
}
|
||||
if (work <= 0) return 0;
|
||||
|
||||
long total = ProgressMs + work;
|
||||
long made = total / MsPerCrystal;
|
||||
if (made <= freeSpace)
|
||||
{
|
||||
ProgressMs = total - made * MsPerCrystal;
|
||||
return (int)made;
|
||||
}
|
||||
ProgressMs = 0; // хранилище заполнилось: шахта встала сразу после последнего кристалла, лишнее не копится
|
||||
return freeSpace;
|
||||
}
|
||||
|
||||
// Через сколько мс следующий кристалл при count работающих шахтах (0 шахт -- -1)
|
||||
public long MsToNext(int count) => count <= 0 ? -1 : Math.Max(0, (MsPerCrystal - ProgressMs + count - 1) / count);
|
||||
|
||||
public void Load(string line)
|
||||
{
|
||||
ProgressMs = 0; LastMs = 0;
|
||||
if (string.IsNullOrEmpty(line)) return;
|
||||
var p = line.Split('|');
|
||||
if (p.Length >= 2
|
||||
&& long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long progress)
|
||||
&& long.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last))
|
||||
{
|
||||
ProgressMs = Math.Max(0, Math.Min(progress, MsPerCrystal - 1));
|
||||
LastMs = Math.Max(0, last);
|
||||
}
|
||||
}
|
||||
|
||||
public string ToLine() => ProgressMs.ToString(CultureInfo.InvariantCulture) + "|" + LastMs.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f3b9d1e8a2c4e70a6d4b8c1f9e27d36
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// НЕДЕЛЬНЫЙ PvE-ПРЕДЕЛ СИНИХ КРИСТАЛЛОВ (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 5.2, 10, 12;
|
||||
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - Не больше Limit (ТЕСТ 6) НОВЫХ синих кристаллов за скользящие WindowMs (7 реальных суток) из PvE: задания,
|
||||
// события, бои, PvE-торговец. Кристальная шахта, торговля и грабёж -- вне предела.
|
||||
// - Take: сколько из награды можно выдать кристаллами сейчас; остальное вызывающий заменяет компенсацией
|
||||
// (золото / ресурсы). На следующую неделю излишек не переносится.
|
||||
// - Квота и место в хранилище -- разные условия: взятое по квоте считается сразу, даже если кристаллы потом
|
||||
// ждут места, -- второй раз квота за них не тратится.
|
||||
// - Операция с тем же id второй раз квоту не берёт (повторное «Забрать», загрузка).
|
||||
// - Время -- метки (мс), окно скользящее: запись старше окна больше не считается. На сервере -- доверенное время.
|
||||
// Без Unity: те же правила будут на сервере и в тестах.
|
||||
public sealed class CrystalQuota
|
||||
{
|
||||
public const long Day = 24L * 60 * 60 * 1000;
|
||||
|
||||
public readonly int Limit;
|
||||
public readonly long WindowMs;
|
||||
|
||||
struct Entry { public long TimeMs; public int Amount; public string Id; }
|
||||
readonly List<Entry> entries = new List<Entry>();
|
||||
|
||||
public CrystalQuota(int limit = 6, long windowMs = 7 * Day)
|
||||
{
|
||||
Limit = Math.Max(0, limit);
|
||||
WindowMs = Math.Max(1, windowMs);
|
||||
}
|
||||
|
||||
// Сколько новых PvE-кристаллов уже выдано за окно, кончающееся в nowMs
|
||||
public int Used(long nowMs)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs <= nowMs) n += e.Amount;
|
||||
return n;
|
||||
}
|
||||
|
||||
public int Left(long nowMs) => Math.Max(0, Limit - Used(nowMs));
|
||||
|
||||
// Когда освободится хотя бы один кристалл квоты (мс); 0 -- уже свободно
|
||||
public long NextFreeMs(long nowMs)
|
||||
{
|
||||
if (Left(nowMs) > 0) return 0;
|
||||
long first = long.MaxValue;
|
||||
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs < first) first = e.TimeMs;
|
||||
return first == long.MaxValue ? 0 : first + WindowMs;
|
||||
}
|
||||
|
||||
public bool Taken(string id) => !string.IsNullOrEmpty(id) && entries.Exists(e => e.Id == id);
|
||||
|
||||
// Взять квоту под награду: сколько из amount выдать кристаллами (остальное -- компенсацией).
|
||||
// Тот же id второй раз -- 0 (уже учтено). Записи старше окна убираются
|
||||
public int Take(string id, int amount, long nowMs)
|
||||
{
|
||||
Prune(nowMs);
|
||||
if (amount <= 0 || Taken(id)) return 0;
|
||||
int give = Math.Min(amount, Left(nowMs));
|
||||
if (give > 0 || !string.IsNullOrEmpty(id)) entries.Add(new Entry { TimeMs = nowMs, Amount = give, Id = id ?? "" });
|
||||
return give;
|
||||
}
|
||||
|
||||
// Можно ли взять amount целиком (покупка у PvE-торговца: при нехватке -- блок, золото не списывается)
|
||||
public bool CanTakeAll(int amount, long nowMs) => amount <= Left(nowMs);
|
||||
|
||||
void Prune(long nowMs) => entries.RemoveAll(e => e.TimeMs <= nowMs - WindowMs);
|
||||
|
||||
// ---------------- Сохранение: строки "время|сколько|id" ----------------
|
||||
|
||||
public List<string> ToLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var e in entries)
|
||||
lines.Add(e.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + e.Amount.ToString(CultureInfo.InvariantCulture) + "|" + e.Id);
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Load(IEnumerable<string> lines)
|
||||
{
|
||||
entries.Clear();
|
||||
if (lines == null) return;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
var p = line.Split(new[] { '|' }, 3);
|
||||
if (p.Length < 2) continue;
|
||||
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
|
||||
if (!int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) || n < 0) continue;
|
||||
entries.Add(new Entry { TimeMs = t, Amount = n, Id = p.Length > 2 ? p[2] : "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e1f4c7a2b6d48a3a8c5f0d2e7b39c14
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// РЕДКИЙ PvE-ТОРГОВЕЦ: синий кристалл за золото (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 3, 10, 12;
|
||||
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - За скользящие 7 реальных суток: 1-я покупка -- Prices[0] (ТЕСТ 30 000 золота), 2-я -- Prices[1] (60 000),
|
||||
// дальше -- недоступно (не больше Prices.Length покупок).
|
||||
// - Покупка входит в недельный PvE-предел (CrystalQuota). Нет квоты -- покупка заблокирована, золото НЕ списывается,
|
||||
// компенсации за оплаченный товар нет.
|
||||
// - Нет места в хранилище -- тоже блок без списания (вместимость получателя проверяется до оплаты).
|
||||
// - Порядок: все проверки -> списание золота (всё или ничего) -> квота -> запись покупки. Одна операция -- один id,
|
||||
// повтор того же id ничего не делает.
|
||||
// Без Unity: те же правила будут на сервере и в тестах.
|
||||
public sealed class CrystalTrader
|
||||
{
|
||||
public enum Result { Bought, NoSlots, NoQuota, NoSpace, NoGold, Repeated }
|
||||
|
||||
public readonly int[] Prices;
|
||||
public readonly long WindowMs;
|
||||
|
||||
struct Purchase { public long TimeMs; public string Id; }
|
||||
readonly List<Purchase> purchases = new List<Purchase>();
|
||||
|
||||
public CrystalTrader(int[] prices = null, long windowMs = 7 * CrystalQuota.Day)
|
||||
{
|
||||
Prices = prices != null && prices.Length > 0 ? prices : new[] { 30000, 60000 };
|
||||
WindowMs = Math.Max(1, windowMs);
|
||||
}
|
||||
|
||||
public int BoughtInWindow(long nowMs)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs <= nowMs) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Цена следующей покупки; -1 -- в этом окне больше нельзя
|
||||
public int PriceNow(long nowMs)
|
||||
{
|
||||
int n = BoughtInWindow(nowMs);
|
||||
return n < Prices.Length ? Prices[n] : -1;
|
||||
}
|
||||
|
||||
// Когда снова можно купить (мс), если покупки кончились; 0 -- можно сейчас
|
||||
public long NextSlotMs(long nowMs)
|
||||
{
|
||||
if (PriceNow(nowMs) >= 0) return 0;
|
||||
long first = long.MaxValue;
|
||||
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs < first) first = p.TimeMs;
|
||||
return first == long.MaxValue ? 0 : first + WindowMs;
|
||||
}
|
||||
|
||||
// Что помешает купить сейчас (без списания); Bought -- можно
|
||||
public Result Check(long nowMs, CrystalQuota quota, int freeSpace)
|
||||
{
|
||||
if (PriceNow(nowMs) < 0) return Result.NoSlots;
|
||||
if (quota != null && !quota.CanTakeAll(1, nowMs)) return Result.NoQuota;
|
||||
if (freeSpace < 1) return Result.NoSpace;
|
||||
return Result.Bought;
|
||||
}
|
||||
|
||||
// Купить 1 синий кристалл. trySpendGold(цена) -- списание «всё или ничего». При Bought кристалл начисляет вызывающий
|
||||
public Result TryBuy(string id, long nowMs, CrystalQuota quota, int freeSpace, Func<int, bool> trySpendGold)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && purchases.Exists(p => p.Id == id)) return Result.Repeated;
|
||||
var check = Check(nowMs, quota, freeSpace);
|
||||
if (check != Result.Bought) return check;
|
||||
int price = PriceNow(nowMs);
|
||||
if (trySpendGold == null || !trySpendGold(price)) return Result.NoGold;
|
||||
if (quota != null) quota.Take(id, 1, nowMs);
|
||||
purchases.RemoveAll(p => p.TimeMs <= nowMs - WindowMs);
|
||||
purchases.Add(new Purchase { TimeMs = nowMs, Id = id ?? "" });
|
||||
return Result.Bought;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение: строки "время|id" ----------------
|
||||
|
||||
public List<string> ToLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var p in purchases) lines.Add(p.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + p.Id);
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Load(IEnumerable<string> lines)
|
||||
{
|
||||
purchases.Clear();
|
||||
if (lines == null) return;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
var p = line.Split(new[] { '|' }, 2);
|
||||
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
|
||||
purchases.Add(new Purchase { TimeMs = t, Id = p.Length > 1 ? p[1] : "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c25a8e3f7d1b4c69b0e4a6f9d8c37b21
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -429,3 +429,307 @@ 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"), "больше ничего не ждёт -- выдан ровно один раз");
|
||||
}
|
||||
}
|
||||
|
||||
// Недельный 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), "шахт нет -- ничего");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -99,11 +99,16 @@
|
||||
- каждый 10-й этап боя — 1 (`StageGemsEvery`, `StageGemsAmount`).
|
||||
|
||||
Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 50 и 200), письмо-приветствие (было 100), подарок за уровень (было 10 × уровень), арена (было 5), этапы боя (было 25 на каждом 5-м), бесплатный сундук (было 15). Вместо них — золото и ресурсы.
|
||||
- **Хранилище — платное расширение (разд. 8 регламента валют, ответ 06.11, п. 1).** Старт 50; ступени (ТЕСТ): Ратуша 3 — 75 (2000 золота, 1500 дерева, 1500 камня), Ратуша 5 — 100 (4000 / 3000 / 3000 + 1000 металла), Ратуша 7 — 150 (7000 / 5000 / 5000 / 2000), Ратуша 9 — 200 (10000 / 8000 / 8000 / 4000). Ратуша только разрешает ступень, покупается в окне Ратуши («Хранилище синих кристаллов» → «Расширить до N»). Цена списывается один раз, всё или ничего. Правила — `Modules/Economy/Rules/CrystalStorage.cs`, тесты — `CrystalStorageTests`; числа — `EconomyConfig` → «Склады и лимиты» → `crystalCap`, `crystalExpansions`. Купленная ступень сохраняется в секции `"economy"` (`crystalStorageLevel`; в старом сохранении её нет — 0), вместимость по ней считается заново.
|
||||
- **Недельный PvE-предел (разд. 5.2, 10, 12 регламента валют; ответ 06.11, п. 2).** Не больше 6 новых синих кристаллов за скользящие 7 реальных суток из заданий, событий и боёв (позже — PvE-торговец). Сверх квоты — 1000 золота за кристалл (компенсация не согласована, ТЕСТ), на следующую неделю не переносится; при частичной квоте — часть кристаллами, остальное золотом. Квота и место в хранилище — разные условия: взятое по квоте, но не поместившееся ждёт места и квоту второй раз не тратит; кристаллы из письма «Кристаллы ждут места» квоту не берут. Задание берёт квоту один раз (по id). Видно в окне Ратуши: «Новых кристаллов из заданий, событий и боёв за 7 дней: N / 6». Правила — `Modules/Economy/Rules/CrystalQuota.cs`, тесты — `CrystalQuotaTests`; числа — `EconomyConfig` → `crystalPveLimit`, `crystalPveWindowDays`, `crystalCompensationGold`; сохранение — `progress.json`, `crystalQuota`. DEV: «+1 PvE-кристалл», «Сбросить квоту».
|
||||
- **Редкий PvE-торговец (разд. 3 и 10 регламента валют).** Магазин → «Редкий торговец: синий кристалл»: за скользящие 7 реальных суток первая покупка — 30 000 золота, вторая — 60 000, третьей нет (`EconomyConfig` → `crystalTraderPrices`, ТЕСТ). Покупка входит в недельный PvE-предел. Все проверки — до оплаты: нет покупок в окне, нет квоты или нет места в хранилище — блок, золото не списывается, компенсации нет. Одна покупка — один id, повтор ничего не делает; после покупки игра сразу сохраняется. Правила — `Modules/Economy/Rules/CrystalTrader.cs`, тесты — `CrystalTraderTests`; сохранение — `progress.json`, `crystalTrader`. DEV: «Ресурсы 100 000» (мимо лимита склада, для проверки), «Сбросить квоту» сбрасывает и покупки. **Вопрос:** при лимите золота 1000 + 1000 за уровень Склада цены 30 000 / 60 000 почти недостижимы.
|
||||
- **Кристальная шахта (разд. 2.1 регламента кристаллов, разд. 8.1 регламента валют, ответ 06.11, п. 3).** Здание «Кристалл» (Ратуша 6; вторая шахта — Ратуша 9, ТЕСТ, лимит в `buildingLimits`). Каждая работающая шахта — 3 синих кристалла за 7 РЕАЛЬНЫХ суток, по одному примерно раз в 56 ч (`crystalMinePerPeriod`, `crystalMinePeriodDays`, ТЕСТ); шахты складываются. Работает с конца стройки (и если стройка закончилась офлайн), досчитывается после входа по меткам; уже посчитанное время второй раз не начисляется, часы назад — ничего. Хранилище полное — пауза без скрытого запаса, после освобождения места — с оставшегося прогресса. В недельный PvE-предел не входит. Окно здания: «Работает шахт: N. Следующий кристалл через …» или «Пауза». Вне 3D-сцены (карта мира) шахты считаются по сохранению `settlement3d`. Правила — `Modules/Economy/Rules/CrystalMine.cs`, тесты — `CrystalMineTests`; расчёт — `GameProgress.ProcessCrystalMine` (раз в секунду), сохранение — `progress.json`, `crystalMine`. DEV «Ресурсы 100 000» — обычные ресурсы мимо лимита склада, для проверки.
|
||||
- **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс.
|
||||
- **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов.
|
||||
|
||||
**Ещё не сделано (после вливания `feature/3d-hud` в `develop`):** вместимость 50 → 75 / 100 / 150 / 200 с платным расширением на Ратуше 3 / 5 / 7 / 9 (сейчас константа `crystalCap` 50); недельный PvE-предел 6 с заменой излишка золотом; редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера.
|
||||
**Ещё не сделано:** космо-кристаллы; торговля и военная добыча — этап мультиплеера.
|
||||
|
||||
**Открыто:** цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты, ускорения за кристаллы (обучение — 1 кристалл за 30 с, стройка) — в регламенте среди трат не названы.
|
||||
**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты (сейчас 9), лимит золота против цен торговца и расширений.
|
||||
|
||||
## Таблица баланса `EconomyConfig`
|
||||
|
||||
@@ -126,6 +131,11 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 06.11.2026 | Claude (Саша) | Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух (вторая — Ратуша 9, ТЕСТ), офлайн по меткам, пауза при полном хранилище, вне недельного предела (`CrystalMine` + 5 тестов, `GameProgress.ProcessCrystalMine`, окно здания). |
|
||||
| 06.11.2026 | Claude (Саша) | Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 реальных суток, в пределах недельной квоты; блок без списания (`CrystalTrader` + 5 тестов, магазин, DEV «Золото 100 000»). |
|
||||
| 06.11.2026 | Claude (Саша) | Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх — 1000 золота за кристалл (ТЕСТ); `CrystalQuota` + 6 тестов, `GameProgress.Grant/ClaimQuest`, окно Ратуши, DEV-кнопки. |
|
||||
| 06.11.2026 | Claude (Саша) | Ускорения стройки, улучшения и обучения войск — за золото: 1 золото за 10 с (`secondsPerGold`, ТЕСТ), было 1 кристалл за 30 с. |
|
||||
| 06.11.2026 | Claude (Саша) | Хранилище синих кристаллов: платное расширение 50 → 75 / 100 / 150 / 200 на Ратуше 3 / 5 / 7 / 9 в окне Ратуши (`CrystalStorage`, `EconomyConfig.crystalExpansions`, `ResourceManager.CrystalStorageLevel` в сохранении, 5 тестов). Вместимость кристаллов больше не константа 50. |
|
||||
| 06.11.2026 | Claude (Саша) | Регламент валют 05.11, разд. 2 и 5.1: синие кристаллы только за верхнюю ступень недельного события (1) и каждый 10-й этап боя (1); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». |
|
||||
| 05.11.2026 | Claude (Саша) | Регламент синих кристаллов, пп. 1 и 3: кристаллы не начисляются сверх 50 и не теряются — награды заданий ждут (не больше 2), остальные — письмом «Кристаллы ждут места», из письма забирается сколько помещается (`CrystalRewards`, `GameProgress.Grant/ClaimQuest/ClaimMail`, 6 тестов). Задания — 1–2 кристалла вместо 20–250, ежедневные — без кристаллов. |
|
||||
| 04.11.2026 | Claude | Решения 04.11: кристаллы грабятся только нападающим с технологией «Грабёж» (`IResourceProtection.Lootable(type, attackerHasPlunder)`; с ней — не больше `plunderGemsShare` запаса, ТЕСТ 10%, точные ограничения — позже). Защита остальных ресурсов без изменений. Из `EconomyConfig` убран возврат при сносе. Офлайн: добыча здания, достроенного в офлайне, — с момента завершения; уже посчитанное время второй раз не начисляется (тесты в `ProductionTests`). |
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. |
|
||||
| 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. |
|
||||
| 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. |
|
||||
| 05.11.2026 | Claude (Саша) | **Решение команды: только 3D-поселение, камера перспективная.** (1) В 3D перенесены Золотой рудник (золото, Ратуша 2, 50 камня), Кристалл (Ратуша 6, цена из `Data_Kristall`) и Терем (`HeroCastle`, окно героя; фигурка героя гуляет только по 2D-сетке, в 3D — нет). (2) Перенос 2D-города: если в сохранении нет `settlement3d`, а есть 2D-город, при первом входе в 3D Ратуша получает уровень и улучшение из 2D, остальные здания встают на ближайшие свободные места с теми же уровнями и стройками (`City3DController.MoveFrom2D`, итог — в Console «[3D] Перенос 2D-города»). Секция `settlement` в файле не меняется. (3) `SceneNavigator.CitySceneName` = City3D; MVP1 выключена в Build Settings (сцена и код 2D остаются в репозитории до конца проверки). (4) `City3DCamera.perspective` (по умолчанию да, поле зрения 40°) — угол, высота и поле зрения ТЕСТ. Меню стройки — в два столбца. |
|
||||
|
||||
Reference in New Issue
Block a user