Feature/3d hud #18
@@ -123,6 +123,8 @@ public static class GameData
|
||||
public const int MaxPlayerLevel = 60;
|
||||
|
||||
// ---------- Основные задания (цепочка) ----------
|
||||
// Синие кристаллы (регламент 05.11, разд. 3): простое задание -- 1, сложное -- 2 (было 20-250). В ежедневных
|
||||
// кристаллов нет: кристальное задание -- не чаще раза в 7-14 дней. Числа ТЕСТ
|
||||
public static readonly List<QuestDef> MainQuests = new List<QuestDef>
|
||||
{
|
||||
new QuestDef("m_collect_wood_1", "Собрать 200 дерева", QuestKind.Collect, 200, 30, R(ResourceType.Gold, 200)).Res(ResourceType.Wood),
|
||||
@@ -130,21 +132,21 @@ public static class GameData
|
||||
new QuestDef("m_build_1", "Построить первое здание", QuestKind.BuildingsBuilt, 1, 50, R(ResourceType.Wood, 300), R(ResourceType.Stone, 200)),
|
||||
new QuestDef("m_barracks", "Построить Казармы", QuestKind.BarracksBuilt, 1, 50, R(ResourceType.Food, 300), R(ResourceType.Wood, 200)),
|
||||
new QuestDef("m_train_10", "Обучить 10 воинов", QuestKind.TroopsTrained, 10, 50, R(ResourceType.Food, 400)),
|
||||
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 30)),
|
||||
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_world_send", "Отправить армию в поход на карте мира", QuestKind.WorldMarches, 1, 60, R(ResourceType.Food, 500)),
|
||||
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 50), R(ResourceType.Gold, 500)),
|
||||
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 2), R(ResourceType.Gold, 500)),
|
||||
new QuestDef("m_hero_3", "Герой 3 уровня", QuestKind.HeroLevel, 3, 60, R(ResourceType.Gold, 500)),
|
||||
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 50), R(ResourceType.Stone, 500)),
|
||||
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 1), R(ResourceType.Stone, 500)),
|
||||
new QuestDef("m_build_3", "Построить 3 здания", QuestKind.BuildingsBuilt, 3, 80, R(ResourceType.Wood, 600), R(ResourceType.Iron, 200)),
|
||||
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 20)),
|
||||
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_collect_stone_1", "Собрать 1000 камня", QuestKind.Collect, 1000, 80, R(ResourceType.Gold, 800)).Res(ResourceType.Stone),
|
||||
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 30)),
|
||||
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 60)),
|
||||
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 100)),
|
||||
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 100), R(ResourceType.Iron, 800)),
|
||||
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 2), R(ResourceType.Iron, 800)),
|
||||
new QuestDef("m_hero_10", "Герой 10 уровня", QuestKind.HeroLevel, 10, 200, R(ResourceType.Gold, 3000)),
|
||||
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 150)),
|
||||
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 250)),
|
||||
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 2)),
|
||||
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 2)),
|
||||
};
|
||||
|
||||
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
|
||||
@@ -154,8 +156,8 @@ public static class GameData
|
||||
new QuestDef("d_wood", "Собрать 300 дерева", QuestKind.Collect, 300, 20, R(ResourceType.Food, 200)).Res(ResourceType.Wood),
|
||||
new QuestDef("d_food", "Собрать 300 еды", QuestKind.Collect, 300, 20, R(ResourceType.Wood, 200)).Res(ResourceType.Food),
|
||||
new QuestDef("d_train", "Обучить 20 воинов", QuestKind.TroopsTrained, 20, 25, R(ResourceType.Iron, 150)),
|
||||
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gems, 10)),
|
||||
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gems, 10)),
|
||||
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gold, 200)),
|
||||
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gold, 200)),
|
||||
new QuestDef("d_world", "Отправить поход на карте мира", QuestKind.WorldMarches, 1, 25, R(ResourceType.Gold, 200)),
|
||||
};
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ public class GameProgress : MonoBehaviour
|
||||
}
|
||||
if (Data.claimedQuests == null) Data.claimedQuests = new List<string>();
|
||||
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
|
||||
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
|
||||
crystals.Load(Data.crystalWaiting);
|
||||
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>();
|
||||
@@ -307,24 +309,76 @@ public class GameProgress : MonoBehaviour
|
||||
return true;
|
||||
}
|
||||
|
||||
// Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят
|
||||
// письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется
|
||||
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
|
||||
{
|
||||
if (ResourceManager.Instance != null && rewards != null)
|
||||
int gems = GemsIn(rewards), 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 (ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
|
||||
foreach (var r in given) ResourceManager.Instance.AddResource(r.type, r.amount);
|
||||
suppressTracking--;
|
||||
}
|
||||
if (left > 0)
|
||||
SendMail("Кристаллы ждут места",
|
||||
$"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма.",
|
||||
new RewardItem(ResourceType.Gems, left));
|
||||
if (xp > 0) AddXp(xp);
|
||||
if (toast)
|
||||
{
|
||||
string text = GameData.RewardsText(rewards);
|
||||
string text = GameData.RewardsText(given.ToArray());
|
||||
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
|
||||
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
|
||||
if (text.Length > 0) UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
|
||||
|
||||
readonly CrystalRewards crystals = new CrystalRewards();
|
||||
|
||||
// Нет склада (сцена без ResourceManager) -- места нет: кристаллы ждут, а не теряются
|
||||
public static float GemsHave => ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(ResourceType.Gems) : 0f;
|
||||
public static float GemsCap => ResourceManager.Instance != null ? ResourceManager.Instance.GetCapacity(ResourceType.Gems) : 0f;
|
||||
|
||||
static int GemsIn(IEnumerable<RewardItem> rewards)
|
||||
{
|
||||
int n = 0;
|
||||
if (rewards != null) foreach (var r in rewards) if (r != null && r.type == ResourceType.Gems) n += Mathf.Max(0, r.amount);
|
||||
return n;
|
||||
}
|
||||
|
||||
static RewardItem[] WithoutGems(IEnumerable<RewardItem> rewards)
|
||||
{
|
||||
var list = new List<RewardItem>();
|
||||
if (rewards != null) foreach (var r in rewards) if (r != null && r.type != ResourceType.Gems) list.Add(r);
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
static string QuestKey(QuestDef q, bool daily) => (daily ? "d:" : "m:") + q.id;
|
||||
|
||||
// Кристаллы награды задания, которые ждут места (0 -- не ждут)
|
||||
public int QuestGemsWaiting(QuestDef q, bool daily) => crystals.Left(QuestKey(q, daily));
|
||||
|
||||
// Начислить кристаллы награды key: сколько помещается, остаток ждёт под этим key (сохраняется)
|
||||
int GiveQuestGems(string key, int amount)
|
||||
{
|
||||
int give = crystals.Claim(key, amount, GemsHave, GemsCap);
|
||||
if (give > 0 && ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, give);
|
||||
suppressTracking--;
|
||||
}
|
||||
Data.crystalWaiting = crystals.ToLines();
|
||||
MarkChanged();
|
||||
return give;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
|
||||
// =====================================================================
|
||||
@@ -455,13 +509,42 @@ public class GameProgress : MonoBehaviour
|
||||
public bool IsQuestClaimed(QuestDef q, bool daily) { return (daily ? Data.claimedDaily : Data.claimedQuests).Contains(q.id); }
|
||||
public bool IsQuestDone(QuestDef q, bool daily) { return QuestProgress(q, daily) >= q.target; }
|
||||
|
||||
// Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут
|
||||
// CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11)
|
||||
public void ClaimQuest(QuestDef q, bool daily)
|
||||
{
|
||||
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
|
||||
string key = QuestKey(q, daily);
|
||||
if (IsQuestClaimed(q, daily))
|
||||
{
|
||||
if (crystals.Left(key) <= 0) return;
|
||||
int got = GiveQuestGems(key, 0); // дозабрать остаток
|
||||
int still = crystals.Left(key);
|
||||
UIKit.Toast(got > 0 ? $"Получено: {got} кристаллов" + (still > 0 ? $". Ещё {still} ждут места" : "")
|
||||
: $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0})");
|
||||
return;
|
||||
}
|
||||
if (!IsQuestDone(q, daily)) return;
|
||||
|
||||
int gems = GemsIn(q.rewards);
|
||||
if (!crystals.CanStart(key, gems, GemsHave, GemsCap))
|
||||
{
|
||||
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
|
||||
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
|
||||
return;
|
||||
}
|
||||
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
|
||||
Data.questsDone++;
|
||||
AddEventPoints(20);
|
||||
Grant(q.rewards, q.xp);
|
||||
|
||||
var rest = WithoutGems(q.rewards);
|
||||
Grant(rest, q.xp, false);
|
||||
int given = gems > 0 ? GiveQuestGems(key, gems) : 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} кристаллов ждут места -- «Забрать» в заданиях";
|
||||
UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
|
||||
public int ClaimableQuestsCount
|
||||
@@ -471,6 +554,12 @@ public class GameProgress : MonoBehaviour
|
||||
int n = 0;
|
||||
foreach (var q in GameData.MainQuests) if (!IsQuestClaimed(q, false) && IsQuestDone(q, false)) n++;
|
||||
foreach (var q in GameData.DailyQuests) if (!IsQuestClaimed(q, true) && IsQuestDone(q, true)) n++;
|
||||
// кристаллы, которые ждали места, -- когда место появилось
|
||||
if (crystals.WaitingCount > 0 && CrystalRewards.Free(GemsHave, GemsCap) > 0)
|
||||
{
|
||||
foreach (var q in GameData.MainQuests) if (IsQuestClaimed(q, false) && QuestGemsWaiting(q, false) > 0) n++;
|
||||
foreach (var q in GameData.DailyQuests) if (IsQuestClaimed(q, true) && QuestGemsWaiting(q, true) > 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -525,11 +614,27 @@ public class GameProgress : MonoBehaviour
|
||||
|
||||
public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } }
|
||||
|
||||
public void ClaimMail(MailItem m, bool toast = true)
|
||||
// Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11).
|
||||
// false -- в письме остались кристаллы, которым нет места
|
||||
public bool ClaimMail(MailItem m, bool toast = true)
|
||||
{
|
||||
if (m == null || m.claimed || !m.HasRewards) return;
|
||||
m.claimed = true; m.read = true;
|
||||
Grant(m.rewards.ToArray(), 0, toast);
|
||||
if (m == null || m.claimed || !m.HasRewards) return true;
|
||||
m.read = true;
|
||||
int gems = GemsIn(m.rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
var give = new List<RewardItem>(WithoutGems(m.rewards));
|
||||
if (give.Count == 0 && fit == 0)
|
||||
{
|
||||
if (toast) UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}) -- подарок подождёт в письме");
|
||||
MarkChanged();
|
||||
return false;
|
||||
}
|
||||
if (fit > 0) give.Add(new RewardItem(ResourceType.Gems, fit));
|
||||
m.rewards.Clear();
|
||||
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
|
||||
else m.claimed = true;
|
||||
Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит
|
||||
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
|
||||
return left == 0;
|
||||
}
|
||||
|
||||
public void DeleteMail(MailItem m)
|
||||
@@ -542,13 +647,16 @@ public class GameProgress : MonoBehaviour
|
||||
|
||||
public void ClaimAllMail()
|
||||
{
|
||||
var all = new List<RewardItem>();
|
||||
foreach (var m in Data.mail)
|
||||
int gifts = 0, waiting = 0;
|
||||
foreach (var m in Data.mail.ToArray())
|
||||
{
|
||||
m.read = true;
|
||||
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
|
||||
if (!m.HasRewards || m.claimed) continue;
|
||||
gifts++;
|
||||
if (!ClaimMail(m, false)) waiting++;
|
||||
}
|
||||
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
|
||||
if (gifts == 0) UIKit.Toast("Все письма прочитаны");
|
||||
else UIKit.Toast(waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места");
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ public class ProgressData
|
||||
public List<string> claimedQuests = new List<string>();
|
||||
public int dailyDay = -1;
|
||||
public List<string> claimedDaily = new List<string>();
|
||||
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
|
||||
public List<string> crystalWaiting = new List<string>();
|
||||
public float[] dailyCollected = new float[8];
|
||||
public int dailyBattles;
|
||||
public int dailyTroops;
|
||||
|
||||
@@ -32,7 +32,9 @@ public class QuestsPanel : BasePanel
|
||||
var shown = new List<QuestDef>();
|
||||
int claimedCount = 0;
|
||||
|
||||
// Сначала готовые к получению, потом в процессе. В основных -- не больше 6 активных.
|
||||
// Сначала кристаллы, которые ждут места (регламент 05.11), потом готовые к получению, потом в процессе.
|
||||
// В основных -- не больше 6 активных.
|
||||
foreach (var q in source) if (GP.IsQuestClaimed(q, daily) && GP.QuestGemsWaiting(q, daily) > 0) shown.Add(q);
|
||||
foreach (var q in source) if (!GP.IsQuestClaimed(q, daily) && GP.IsQuestDone(q, daily)) shown.Add(q);
|
||||
foreach (var q in source)
|
||||
{
|
||||
@@ -62,6 +64,15 @@ public class QuestsPanel : BasePanel
|
||||
UIKit.Bar(card, (float)progress / q.target, done ? UIKit.Theme.Green : UIKit.Theme.Gold, 30, progress + " / " + q.target);
|
||||
|
||||
var row = UIKit.Row(card, 80, 12);
|
||||
int waiting = GP.QuestGemsWaiting(q, daily);
|
||||
if (waiting > 0)
|
||||
{
|
||||
// Награда получена, кроме кристаллов: им не было места (хранилище 50). Ждут, не сгорают
|
||||
var wait = UIKit.Text(row, $"Ждут места: {waiting} кристаллов (хранилище {GameProgress.GemsHave:0} / {GameProgress.GemsCap:0})", 24, UIKit.Theme.Gold);
|
||||
UIKit.Size(wait, -1, -1, 1);
|
||||
UIKit.Button(row, "Забрать", UIKit.Theme.Green, () => GP.ClaimQuest(q, daily), 220, 80);
|
||||
return;
|
||||
}
|
||||
var reward = UIKit.Text(row, "Награда: " + GameData.RewardsText(q.rewards) + ", " + q.xp + " опыта", 24, UIKit.Theme.TextDim);
|
||||
UIKit.Size(reward, -1, -1, 1);
|
||||
var b = UIKit.Button(row, done ? "Забрать" : "В процессе", done ? UIKit.Theme.Green : UIKit.Theme.Gray,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// СИНИЕ КРИСТАЛЛЫ ПРИ ПРЕДЕЛЕ ХРАНИЛИЩА (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 1).
|
||||
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - Начислить сверх предела нельзя, «тихо потерять» излишек -- тоже нельзя.
|
||||
// - Награда, которая не помещается, выдаётся частично (сколько есть места), остаток ждёт («Можно забрать»)
|
||||
// и выдаётся позже -- не сгорает. Повторное «Забрать», загрузка, перезапуск не выдают кристалл дважды:
|
||||
// каждая награда -- по своему id, остаток хранится вместе с id.
|
||||
// - Одновременно ждут не больше MaxWaiting наград. Пока оба места заняты, новая награда, которая не помещается
|
||||
// целиком, не выдаётся совсем (задание остаётся «Забрать») -- ничего не теряется.
|
||||
// Без Unity: те же правила будут на сервере и в тестах. Сколько кристаллов есть и предел -- даёт вызывающий.
|
||||
public sealed class CrystalRewards
|
||||
{
|
||||
public const int MaxWaiting = 2;
|
||||
|
||||
readonly Dictionary<string, int> waiting = new Dictionary<string, int>();
|
||||
|
||||
public int WaitingCount => waiting.Count;
|
||||
public IEnumerable<KeyValuePair<string, int>> Waiting => waiting;
|
||||
|
||||
// Сколько ещё помещается: целые кристаллы до предела. Предела нет -- сколько угодно
|
||||
public static int Free(float have, float cap)
|
||||
{
|
||||
if (float.IsInfinity(cap) || float.IsNaN(cap)) return int.MaxValue;
|
||||
return Math.Max(0, (int)Math.Floor(cap - have + 0.0001f));
|
||||
}
|
||||
|
||||
public static int Fits(int amount, float have, float cap) => Math.Max(0, Math.Min(amount, Free(have, cap)));
|
||||
|
||||
// Сколько кристаллов этой награды ещё ждут места (0 -- не ждут)
|
||||
public int Left(string id) => id != null && waiting.TryGetValue(id, out int n) ? n : 0;
|
||||
|
||||
// Можно ли начать выдачу новой награды: поместится целиком, уже ждёт или есть свободное место ожидания
|
||||
public bool CanStart(string id, int amount, float have, float cap)
|
||||
{
|
||||
if (amount <= 0 || Fits(amount, have, cap) >= amount) return true;
|
||||
return Left(id) > 0 || waiting.Count < MaxWaiting;
|
||||
}
|
||||
|
||||
// Выдать награду id: возвращает, сколько кристаллов начислить сейчас; остаток ждёт под этим id.
|
||||
// Если награда уже ждёт -- выдаётся её остаток (amount не важен: награда одна, повтор её не увеличит).
|
||||
// Перед новой наградой вызывающий проверяет CanStart
|
||||
public int Claim(string id, int amount, float have, float cap)
|
||||
{
|
||||
if (id == null) id = "";
|
||||
int need = waiting.TryGetValue(id, out int left) ? left : Math.Max(0, amount);
|
||||
if (need <= 0) return 0;
|
||||
int give = Fits(need, have, cap);
|
||||
if (need - give > 0) waiting[id] = need - give;
|
||||
else waiting.Remove(id);
|
||||
return give;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение: строки "id|сколько" ----------------
|
||||
|
||||
public List<string> ToLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var pair in waiting) lines.Add(pair.Key + "|" + pair.Value.ToString(CultureInfo.InvariantCulture));
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Load(IEnumerable<string> lines)
|
||||
{
|
||||
waiting.Clear();
|
||||
if (lines == null) return;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
int bar = line.LastIndexOf('|');
|
||||
if (bar <= 0) continue;
|
||||
if (int.TryParse(line.Substring(bar + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) && n > 0)
|
||||
waiting[line.Substring(0, bar)] = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c3e9a41b2d84f6e9a0c5d1e8f2b4a67
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -359,3 +359,73 @@ public class ProductionTests
|
||||
Assert.AreEqual(7, times.Count, "6 шагов по 10 минут + конец");
|
||||
}
|
||||
}
|
||||
|
||||
// Синие кристаллы при пределе 50 (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 6)
|
||||
public class CrystalRewardsTests
|
||||
{
|
||||
const float Cap = 50f;
|
||||
|
||||
[Test]
|
||||
public void Crystals_49of50_Reward2_OneGiven_OneWaits()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
Assert.AreEqual(1, r.Claim("q1", 2, 49, Cap), "49/50 + 2 -> выдан 1");
|
||||
Assert.AreEqual(1, r.Left("q1"), "1 ждёт места");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_Full_OldReward15_NothingGiven_NothingLost()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
Assert.AreEqual(0, r.Claim("old", 15, 50, Cap), "50/50 + 15 из старых данных -> 0 выдано");
|
||||
Assert.AreEqual(15, r.Left("old"), "награда ждёт целиком, ничего не исчезло");
|
||||
Assert.AreEqual(10, r.Claim("old", 15, 40, Cap), "освободили 10 -- выдано 10");
|
||||
Assert.AreEqual(5, r.Left("old"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_RepeatClaim_NeverGivesTwice()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
r.Claim("q", 2, 49, Cap); // выдан 1, ждёт 1
|
||||
Assert.AreEqual(0, r.Claim("q", 2, 50, Cap), "повторное «Забрать» при 50/50 -- ничего");
|
||||
Assert.AreEqual(1, r.Left("q"), "остаток не вырос: награда одна");
|
||||
Assert.AreEqual(1, r.Claim("q", 2, 30, Cap), "место есть -- выдан только остаток 1, а не 2");
|
||||
Assert.AreEqual(0, r.Left("q"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_AtMostTwoWaiting_ThirdNotStarted_FittingAlwaysOk()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
r.Claim("a", 2, 50, Cap);
|
||||
r.Claim("b", 2, 50, Cap);
|
||||
Assert.AreEqual(2, r.WaitingCount);
|
||||
Assert.IsFalse(r.CanStart("c", 2, 50, Cap), "третья награда при 50/50 не выдаётся -- задание остаётся «Забрать»");
|
||||
Assert.IsTrue(r.CanStart("a", 2, 50, Cap), "ждущую можно дозабирать");
|
||||
Assert.IsTrue(r.CanStart("c", 2, 40, Cap), "помещается целиком -- место ожидания не нужно");
|
||||
Assert.IsTrue(r.CanStart("d", 0, 50, Cap), "награда без кристаллов -- всегда");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_Waiting_SurvivesSaveAndLoad()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
r.Claim("m:m_th_3", 2, 50, Cap);
|
||||
r.Claim("mail_7", 5, 48, Cap);
|
||||
var copy = new CrystalRewards();
|
||||
copy.Load(r.ToLines());
|
||||
Assert.AreEqual(2, copy.Left("m:m_th_3"));
|
||||
Assert.AreEqual(3, copy.Left("mail_7"));
|
||||
Assert.AreEqual(2, copy.WaitingCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_NeverAboveCap()
|
||||
{
|
||||
Assert.AreEqual(0, CrystalRewards.Free(50, Cap));
|
||||
Assert.AreEqual(0, CrystalRewards.Free(51, Cap), "выше предела (старые данные) -- места нет, а не минус");
|
||||
Assert.AreEqual(3, CrystalRewards.Fits(10, 47, Cap));
|
||||
Assert.AreEqual(10, CrystalRewards.Fits(10, 0, float.PositiveInfinity), "без предела -- целиком");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,17 @@
|
||||
| `App/BuildingWindow.cs` | Окно здания и рабочих. |
|
||||
| `App/StorageBuilding.cs`, `App/StorageBootstrap.cs` | Склад и Амбар. |
|
||||
|
||||
## Синие кристаллы при пределе 50 (регламент 05.11.2026, ред. 1.1)
|
||||
|
||||
Предел — строго 50, не повышается. Правила — `Modules/Economy/Rules/CrystalRewards.cs` (без Unity), тесты — `CrystalRewardsTests`.
|
||||
|
||||
- **Не сверх предела и без потерь.** Кристаллы, которым нет места, не начисляются и не пропадают.
|
||||
- **Задания.** Выдаётся, сколько помещается; остаток ждёт в задании (в окне заданий — «Ждут места: N кристаллов», кнопка «Забрать»), не сгорает. Одновременно ждут не больше **2** наград; пока оба места заняты, задание, кристаллы которого не помещаются целиком, не выдаётся совсем — остаётся «Забрать». Сохраняется в `progress.json`: `crystalWaiting` («m:<задание>|сколько»).
|
||||
- **Остальные награды** (вход, события, бои, почта уровня): то, что не поместилось, приходит письмом «Кристаллы ждут места». Из письма забирается, сколько помещается, остаток остаётся в письме.
|
||||
- **Числа заданий (разд. 3):** простое — 1 кристалл, сложное — 2 (было 20–250). В ежедневных заданиях кристаллов нет (было 10) — вместо них 200 золота. ТЕСТ.
|
||||
- **Ещё не сделано:** Кристальная шахта (3 за 7 реальных суток), недельный PvE-предел 6, точки на карте, торговля, грабёж с пределом получателя.
|
||||
- **Вопросы команде:** источники, которых нет в регламенте, — награды за вход (50 и 200), письмо-приветствие (100), подарок за уровень (10 × уровень), события, обмен золота на кристаллы и покупки в магазине. Сейчас они не теряют кристаллы, но дают их много.
|
||||
|
||||
## Таблица баланса `EconomyConfig`
|
||||
|
||||
Все числа экономики — в ассете `Assets/Resources/EconomyConfig.asset` (класс `App/EconomyConfig.cs`, слой приложения). Меняются в Inspector без перекомпиляции. Разделы: население, стартовые ресурсы новой игры, производство T1, месторождения, цена солдата. Числа с пометкой «не утверждено» — временные.
|
||||
@@ -109,6 +120,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 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`). |
|
||||
| 01.11.2026 | Claude | `EconomyConfig`: разделы «Стройка» (время, строители, ускорение, возврат при сносе), «Лимит зданий по уровню Ратуши», «Защита ресурсов от грабежа» — всё ТЕСТ. Защита: без Складов 100 каждого ресурса, +200 за уровень Склада; еда 100, +300 за уровень Амбара; кристаллы не грабятся. Контракт `IResourceProtection` (`App/ResourceProtection`), в окне Склада и Амбара — «спрятано / можно унести». |
|
||||
| 31.10.2026 | Claude | Новые места после стройки и улучшения больше не заполняются сами (`App/CitySync`), только стартовая расстановка один раз. |
|
||||
|
||||
Reference in New Issue
Block a user