Feature/blue crystals #19
@@ -336,6 +336,15 @@ public static class BuildingWindow
|
||||
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);
|
||||
|
||||
@@ -77,6 +77,11 @@ 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(); });
|
||||
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(); });
|
||||
|
||||
@@ -98,6 +98,13 @@ public class EconomyConfig : ScriptableObject
|
||||
public float perGranaryLevel = 1500f;
|
||||
[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("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " +
|
||||
"Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")]
|
||||
public CrystalExpansion[] crystalExpansions =
|
||||
|
||||
@@ -146,6 +146,10 @@ 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.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 +313,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 +347,47 @@ 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;
|
||||
}
|
||||
|
||||
// Только для проверки (DEV): забыть недельную квоту
|
||||
public void DevResetCrystalQuota()
|
||||
{
|
||||
quota.Load(null);
|
||||
Data.crystalQuota = quota.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 +577,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 +587,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 +687,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 +842,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;
|
||||
}
|
||||
@@ -896,7 +951,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,8 @@ 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>();
|
||||
public float[] dailyCollected = new float[8];
|
||||
public int dailyBattles;
|
||||
public int dailyTroops;
|
||||
|
||||
@@ -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:
|
||||
@@ -514,3 +514,73 @@ public class CrystalStorageTests
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +100,11 @@
|
||||
|
||||
Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 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-кристалл», «Сбросить квоту».
|
||||
- **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс.
|
||||
- **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов.
|
||||
|
||||
**Ещё не сделано:** недельный PvE-предел 6 с заменой излишка золотом; редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера.
|
||||
**Ещё не сделано:** редкий PvE-торговец (30 000 / 60 000 золота, не больше 2 за 7 суток); Кристальная шахта (до двух, 3 за 7 реальных суток, пауза при полном хранилище); космо-кристаллы; торговля и военная добыча — этап мультиплеера.
|
||||
|
||||
**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты.
|
||||
|
||||
@@ -128,6 +129,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 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); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». |
|
||||
|
||||
Reference in New Issue
Block a user