Пропавшие без вести (решения 01.11, п. 5)

Бой: отдельный статус среди выбывших (доля не утверждена -- 0, выключено), версия правил 8.
Армия: пропавшие из похода -- в группу; окно 6 игровых часов после обратного пути, возвращение 2-4 группами,
70% здоровыми / 30% ранеными, остальные списываются; сохранение без повторного возвращения.
Население: занимают места, но не едят. Окна: сколько пропало и сколько ещё может вернуться.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-04 15:16:43 +01:00
co-authored by Claude Opus 5.5
parent cfbdfd9876
commit 6c39c8b102
24 changed files with 492 additions and 21 deletions
+2 -1
View File
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
m_Name: BattleConfig
m_EditorClassIdentifier:
version: 7
version: 8
counterPercent: 25
wallsPercent: 20
siegeVsWallsPercent: 50
@@ -49,3 +49,4 @@ MonoBehaviour:
- 450
- 300
- 200
missingSharePermille: 0
+4
View File
@@ -20,3 +20,7 @@ MonoBehaviour:
healGameHours: 04000000040000000600000008000000
healFoodPerSoldier: 00000000000000000000000000000000
healGoldPerSoldier: 01000000010000000200000003000000
missingReturnPercent: 50
missingWoundedPercent: 30
missingWindowGameHours: 6
missingMaxGroups: 4
+4
View File
@@ -42,6 +42,9 @@ public class BattleConfig : ScriptableObject
[Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")]
public int[] deadSharePermille = { 700, 450, 300, 200 };
[Tooltip("Пропавшие без вести (решения 01.11, п. 5): доля выбывших, десятые доли процента. НЕ УТВЕРЖДЕНА -- 0, механика выключена. Для проверки -- например 150 (15%)")]
public int missingSharePermille = 0;
[Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")]
public int famineStrengthPercent = 50;
[Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")]
@@ -78,6 +81,7 @@ public class BattleConfig : ScriptableObject
WeakLossMaxPermille = weakLossMaxPermille,
DeadRatioPermille = deadRatioPermille,
DeadSharePermille = deadSharePermille,
MissingSharePermille = Mathf.Clamp(missingSharePermille, 0, 1000),
RandomPercent = Mathf.Max(0, randomPercent),
FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100),
FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100)
+13 -1
View File
@@ -42,7 +42,7 @@ public static class GameModules
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules()); // госпиталь (решения 31.10, п. 7)
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules(), HospitalConfig.Load().ToMissingRules()); // госпиталь, пропавшие
GameServices.Register<IArmy>(army);
SaveRegistry.Register(army);
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
@@ -50,6 +50,18 @@ public static class GameModules
// Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится
EventBus.Subscribe<TroopsTrainedEvent>(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); });
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyTraining(army));
// Пропавшие без вести вернулись / списаны (решения 01.11, п. 5)
EventBus.Subscribe<MissingReturnedEvent>(e =>
{
if (e.Healthy + e.Wounded > 0)
UIKit.Toast($"Вернулись пропавшие без вести: {e.Healthy + e.Wounded}" + (e.Wounded > 0 ? $" (ранены {e.Wounded} -- в госпиталь)" : ""));
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
});
EventBus.Subscribe<MissingWrittenOffEvent>(e =>
{
if (e.Count > 0) UIKit.Toast($"Не вернулись и списаны пропавшие без вести: {e.Count}");
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
});
// Раненые вылечены -- вернулись в город
EventBus.Subscribe<SoldiersHealedEvent>(e =>
{
+21
View File
@@ -33,6 +33,16 @@ public class HospitalConfig : ScriptableObject
[Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")]
public int[] healGoldPerSoldier = { 1, 1, 2, 3 };
[Header("Пропавшие без вести (решения 01.11, п. 5). Долю пропавших в бою задаёт BattleConfig.missingSharePermille")]
[Tooltip("Сколько пропавших вернётся, %. Утверждён диапазон 10-90%, формула НЕ утверждена -- ТЕСТ")]
public int missingReturnPercent = 50;
[Tooltip("Из вернувшихся ранены, %. Утверждено: 30 (70% здоровыми)")]
public int missingWoundedPercent = 30;
[Tooltip("Окно возвращения после обратного пути, ИГРОВЫХ часов. Утверждено: до 6")]
public int missingWindowGameHours = 6;
[Tooltip("Возвращаются группами: от 2 до стольких. Утверждено: 2-4")]
public int missingMaxGroups = 4;
static HospitalConfig cached;
public static HospitalConfig Load()
@@ -47,6 +57,17 @@ public class HospitalConfig : ScriptableObject
return cached;
}
public MissingRules ToMissingRules()
{
return new MissingRules
{
ReturnPercent = Mathf.Clamp(missingReturnPercent, 0, 100),
WoundedPercent = Mathf.Clamp(missingWoundedPercent, 0, 100),
WindowGameHours = Mathf.Max(0, missingWindowGameHours),
MaxGroups = Mathf.Max(2, missingMaxGroups)
};
}
public HospitalRules ToRules()
{
var hours = new int[TroopType.Count];
+1
View File
@@ -721,6 +721,7 @@ public class GameProgress : MonoBehaviour
long now = GameClock.UnixNowMs;
army.ProcessTraining(now);
army.ProcessHealing(now);
army.ProcessMissing(now);
}
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
@@ -45,6 +45,13 @@ public class TroopsPanel : BasePanel
ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")");
ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next);
}
if (hurt.MissingTotal > 0)
{
long windowEnd = 0;
foreach (var m in hurt.Missing) windowEnd = System.Math.Max(windowEnd, m.EndMs);
ProfilePanel.KV(army, "Пропали без вести", hurt.MissingTotal.ToString("N0") + " (могут вернуться ещё " +
GameData.FormatTime(System.Math.Max(0, windowEnd - GameClock.UnixNowMs) / 1000.0) + ")");
}
ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0"));
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
+223 -2
View File
@@ -29,6 +29,18 @@ public sealed class HospitalRules
}
}
// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig)
public sealed class MissingRules
{
public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта)
public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными)
public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов
public long GameHourMs = 5 * 60 * 1000;
public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало)
public static MissingRules Default => new MissingRules();
}
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
@@ -62,6 +74,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant
private int nextHealId = 1;
private readonly HospitalRules hospital;
// Пропавшие без вести одного боя: группы возвращения рассчитаны сразу, при постановке (детерминированно)
private class MissingEvent
{
public int id, marchId;
public long windowStartMs, endMs;
public int[] remaining = new int[Types]; // ещё не вернулись и не списаны
public List<MissingGroup> groups = new List<MissingGroup>();
}
private class MissingGroup
{
public int index;
public long timeMs;
public int[] healthy = new int[Types], wounded = new int[Types];
}
private readonly List<MissingEvent> missing = new List<MissingEvent>();
private readonly List<MissingInfo> missingView = new List<MissingInfo>();
private readonly HashSet<int> missingMarches = new HashSet<int>(); // по каким походам пропавшие уже отмечены
private int nextMissingId = 1;
private readonly MissingRules missingRules;
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
private int nextOrderId = 1;
@@ -76,10 +110,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
private readonly int baseMarchSlots;
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null)
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null, MissingRules missingRules = null)
{
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
this.hospital = hospital ?? HospitalRules.Default;
this.missingRules = missingRules ?? MissingRules.Default;
}
public HospitalRules Hospital => hospital;
@@ -247,6 +282,138 @@ public sealed class ArmyService : IArmy, ISaveParticipant
return n;
}
// ---------------- Пропавшие без вести ----------------
public int MissingTotal
{
get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; }
}
public IReadOnlyList<MissingInfo> Missing
{
get
{
missingView.Clear();
foreach (var e in missing)
{
int n = 0;
foreach (var r in e.remaining) n += r;
missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs });
}
return missingView;
}
}
public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs)
{
if (missingUnits == null || missingMarches.Contains(marchId)) return false;
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
var e = new MissingEvent { id = nextMissingId++, marchId = marchId };
int total = 0;
for (int i = 0; i < Types && i < missingUnits.Length; i++)
{
int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i]));
entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте
e.remaining[i] = n;
total += n;
}
if (total == 0) { nextMissingId--; return false; }
e.windowStartMs = battleMs + Math.Max(0, travelMs);
e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs;
PlanGroups(e, battleMs);
missing.Add(e);
missingMarches.Add(marchId);
Changed();
return true;
}
// Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне,
// в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется.
void PlanGroups(MissingEvent e, long seedTime)
{
ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime;
int roll = 0;
bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num;
var back = new int[Types];
int returning = 0;
for (int i = 0; i < Types; i++)
{
long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent));
back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0);
returning += back[i];
}
if (returning == 0) return;
int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше
if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1))));
long window = Math.Max(1, e.endMs - e.windowStartMs);
for (int g = 0; g < groups; g++)
e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) });
// Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые
for (int i = 0; i < Types; i++)
for (int k = 0; k < back[i]; k++)
{
var grp = e.groups[(k + i) % groups];
if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++;
else grp.healthy[i]++;
}
}
static ulong Next(ulong seed, int n)
{
ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1);
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
public int ProcessMissing(long nowMs)
{
int done = 0;
bool changed = false;
for (int k = missing.Count - 1; k >= 0; k--)
{
var e = missing[k];
e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs));
while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs)
{
var g = e.groups[0];
e.groups.RemoveAt(0); // группа возвращается один раз
int healthy = 0, wounded = 0;
bool anyWounded = false;
for (int i = 0; i < Types; i++)
{
int h = Math.Min(g.healthy[i], e.remaining[i]);
int w = Math.Min(g.wounded[i], e.remaining[i] - h);
e.remaining[i] -= h + w;
troops[i] += h; // исходной армии может уже не быть -- в резерв поселения
woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам
healthy += h; wounded += w;
if (w > 0) anyWounded = true;
}
if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); }
done++;
changed = true;
EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs });
}
if (nowMs >= e.endMs)
{
int left = 0;
foreach (var r in e.remaining) left += r;
missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены
changed = true;
EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs });
}
}
if (changed) Changed();
return done;
}
public int GetWoundedWaiting(int type)
{
return type >= 0 && type < Types ? woundedWaiting[type] : 0;
@@ -425,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal });
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
Missing = MissingTotal });
}
// ---------------- Перенос из старого progress.json ----------------
@@ -462,6 +630,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
// healNext=<номер следующей группы лечения>
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
public string SaveKey => "army";
@@ -486,6 +657,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant
N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture)
})).Append('\n');
sb.Append("healNext=").Append(N(nextHealId)).Append('\n');
foreach (var e in missing)
{
sb.Append("missing=").Append(string.Join("|", new[]
{
N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining)
})).Append('\n');
foreach (var g in e.groups)
sb.Append("mgroup=").Append(string.Join("|", new[]
{
N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded)
})).Append('\n');
}
foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n');
sb.Append("missingNext=").Append(N(nextMissingId)).Append('\n');
return sb.ToString();
}
@@ -499,6 +684,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Array.Clear(woundedWaiting, 0, Types);
healing.Clear();
nextHealId = 1;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
@@ -556,6 +744,36 @@ public sealed class ArmyService : IArmy, ISaveParticipant
nextHealId = Math.Max(nextHealId, hid + 1);
}
}
else if (line.StartsWith("missing="))
{
string[] p = line.Substring("missing=".Length).Split('|');
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid)
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart)
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend)
&& !missing.Exists(x => x.id == mid))
{
missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) });
nextMissingId = Math.Max(nextMissingId, mid + 1);
}
}
else if (line.StartsWith("mgroup="))
{
string[] p = line.Substring("mgroup=".Length).Split('|');
MissingEvent owner = null;
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid))
owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп
if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime)
&& !owner.groups.Exists(x => x.index == gidx))
owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) });
}
else if (line.StartsWith("missingMarch=")
&& int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm))
missingMarches.Add(mm);
else if (line.StartsWith("missingNext=")
&& int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext))
nextMissingId = Math.Max(nextMissingId, mnext);
else if (line.StartsWith("healNext=")
&& int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext))
nextHealId = Math.Max(nextHealId, hnext);
@@ -579,6 +797,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Array.Clear(woundedWaiting, 0, Types);
healing.Clear();
nextHealId = 1;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
Changed();
}
+7 -2
View File
@@ -6,9 +6,10 @@
// итог боя запоминает версию, по которой посчитан.
public sealed class BattleRules
{
public int Version = 7; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
public int Version = 8; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
// 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7);
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1)
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1);
// 8 -- пропавшие без вести (п. 5)
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
public int[] UnitPower = { 4, 5, 9, 16 };
@@ -37,6 +38,10 @@ public sealed class BattleRules
public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1
public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20%
// Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента.
// НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше.
public int MissingSharePermille = 0;
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
public int FamineStrengthPercent = 50; // голод: -50%
public int FatigueStrengthPercent = 80; // усталость: -20%
+35 -9
View File
@@ -181,15 +181,20 @@ public sealed class BattleSystem : IBattleSystem
int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding);
int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding);
// Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны
int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding);
int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding);
// Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7):
// доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе
int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding);
int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding);
int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; }
int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding);
int[] defDead = Dead(defRest, defPower, attPower, request.Seed, side: 2, rounding);
int[] attWounded = new int[TroopType.Count];
int[] defWounded = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attWounded[i] = attOut[i] - attDead[i];
defWounded[i] = defOut0[i] - defDead[i];
attWounded[i] = attRest[i] - attDead[i];
defWounded[i] = defRest[i] - defDead[i];
}
var attAlive = new int[TroopType.Count];
@@ -197,9 +202,9 @@ public sealed class BattleSystem : IBattleSystem
var defOut = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attAlive[i] = att[i] - attDead[i] - attWounded[i];
defAlive[i] = def[i] - defDead[i] - defWounded[i];
defOut[i] = defDead[i] + defWounded[i];
attAlive[i] = att[i] - attOut[i];
defAlive[i] = def[i] - defOut0[i];
defOut[i] = defOut0[i];
}
return new BattleOutcome
@@ -207,7 +212,7 @@ public sealed class BattleSystem : IBattleSystem
Victory = victory,
BattleId = request.Seed,
RulesVersion = rules.Version,
AttackerLosses = Sum(attDead) + Sum(attWounded),
AttackerLosses = Sum(attOut),
DefenderLosses = Sum(defOut),
Survivors = Sum(attAlive),
AttackerPower = attPower.Floor, // округление -- только для показа
@@ -224,6 +229,8 @@ public sealed class BattleSystem : IBattleSystem
DefenderDeadUnits = defDead,
AttackerWoundedUnits = attWounded,
DefenderWoundedUnits = defWounded,
AttackerMissingUnits = attMissing,
DefenderMissingUnits = defMissing,
DefenderLossUnits = defOut
};
}
@@ -340,6 +347,25 @@ public sealed class BattleSystem : IBattleSystem
return lost;
}
// Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок
// из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше
int[] Missing(int[] outUnits, long seed, int side, Rounding rounding)
{
var missing = new int[TroopType.Count];
int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille));
if (share == 0) return missing;
for (int i = 0; i < TroopType.Count; i++)
{
if (outUnits[i] <= 0) continue;
long num = (long)outUnits[i] * share;
long whole = num / 1000, rest = num % 1000;
bool extra = rest != 0 && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest);
missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0));
}
return missing;
}
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок
// из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше.
int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding)
@@ -86,6 +86,15 @@ public interface IPopulation
}
// ---------------- Армия: войска ----------------
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
public struct MissingInfo
{
public int Id;
public int Count; // ещё не вернулись и не списаны
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
public long EndMs; // конец окна: кто не вернулся -- списан
}
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
public struct HealingInfo
{
@@ -151,6 +160,16 @@ public interface IArmy
// Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11).
int WoundedTotal { get; } // раненых всего: ждут места + лечатся
int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4)
// ---- Пропавшие без вести (решения 01.11, п. 5) ----
// Не воюют и не едят, но занимают места в населении до возвращения или списания.
int MissingTotal { get; }
IReadOnlyList<MissingInfo> Missing { get; }
// Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь.
// Только известный поход и только один раз на поход; больше, чем ушло, не забрать.
bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs);
// Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось.
int ProcessMissing(long nowMs);
int GetWoundedWaiting(int type); // ждут места, по типам
IReadOnlyList<HealingInfo> Healing { get; } // лечатся сейчас
int HospitalBeds { get; } // мест в госпитале сейчас
@@ -299,7 +318,8 @@ public struct BattleOutcome
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
public int[] AttackerDeadUnits, DefenderDeadUnits;
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
}
@@ -63,6 +63,25 @@ public struct TroopsChangedEvent
public int InMarches;
public int Garrison; // из AtHome -- в гарнизоне
public int Wounded; // раненые: в госпитале или ждут места (не в AtHome)
public int Missing; // пропавшие без вести: занимают места в населении, но не едят и не воюют
}
// Пропавшие без вести вернулись (часть группы боя): здоровые -- в город, раненые -- в госпиталь. Один раз на группу
public struct MissingReturnedEvent
{
public int MissingId;
public int Group;
public int Healthy;
public int Wounded;
public long TimeMs;
}
// Окно возвращения кончилось: кто не вернулся -- списан, места в населении освобождены
public struct MissingWrittenOffEvent
{
public int MissingId;
public int Count;
public long TimeMs;
}
// Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили
@@ -117,6 +136,7 @@ public struct MarchReturnedEvent
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
public int Missing; // пропали без вести в бою -- могут вернуться сами
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
@@ -79,7 +79,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
get
{
if (!GameServices.TryGet(out IArmy army)) return 0;
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal; // раненые -- тоже люди (решения 31.10, п. 7)
// раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5)
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal;
}
}
@@ -189,7 +190,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
double SoldiersFood(int soldiers)
{
var table = rules.FoodPerTroopTypePerHour;
if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return soldiers * rules.FoodPerSoldierPerHour;
int missingNow = GameServices.TryGet(out IArmy a) ? a.MissingTotal : 0; // пропавшие не едят
if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return Math.Max(0, soldiers - missingNow) * rules.FoodPerSoldierPerHour;
double food = 0;
for (int t = 0; t < army.TypeCount; t++)
food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour);
@@ -1979,4 +1979,101 @@ public class ModuleTests
Assert.AreEqual(civilians + expected, population.FoodPerHour, 0.001, "ждущие койку раненые едят полную норму");
Assert.Less(expected, 5.6, "погибшие больше не едят");
}
// ---------------- Пропавшие без вести (решения 01.11, п. 5) ----------------
[Test]
public void Battle_Missing_IsSeparateStatus_OffByDefault()
{
BattleOutcome Fight(int share)
{
var rules = BattleRules.NoRandom;
rules.MissingSharePermille = share;
return new BattleSystem(rules).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000, Seed = 5 });
}
BattleOutcome off = Fight(0);
Assert.AreEqual(0, off.AttackerMissingUnits[0], "доля не утверждена -- по умолчанию пропавших нет");
BattleOutcome on = Fight(200); // 20% выбывших
Assert.AreEqual(90, on.AttackerMissingUnits[0], "20% от 450 выбывших");
Assert.AreEqual(450, on.AttackerDeadUnits[0] + on.AttackerWoundedUnits[0] + on.AttackerMissingUnits[0], "каждый -- ровно в одном статусе");
Assert.AreEqual(off.Survivors, on.Survivors, "выжившие не меняются");
Assert.AreEqual(off.AttackerLosses, on.AttackerLosses);
}
static ArmyService MissingArmy(MissingRules rules = null)
{
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 }, rules ?? new MissingRules());
GameServices.Register<IArmy>(army);
army.AddTroops(0, 60);
army.AddTroops(1, 40);
Assert.IsTrue(army.TakeForMarch(1, 100));
return army;
}
[Test]
public void Army_Missing_ReturnInGroups_ThenWrittenOff()
{
const long H = 5 * 60_000; // игровой час
var army = MissingArmy(new MissingRules { ReturnPercent = 50, WoundedPercent = 30 });
var back = new List<MissingReturnedEvent>();
var off = new List<MissingWrittenOffEvent>();
EventBus.Subscribe<MissingReturnedEvent>(e => back.Add(e));
EventBus.Subscribe<MissingWrittenOffEvent>(e => off.Add(e));
long battle = 1000, travel = 8 * H;
Assert.IsTrue(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel));
Assert.IsFalse(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel), "один раз на поход");
Assert.AreEqual(40, army.MissingTotal);
Assert.AreEqual(60, army.TroopsInMarches, "пропавшие ушли из похода");
Assert.AreEqual(battle + travel, army.Missing[0].WindowStartMs, "окно -- после обратного пути");
Assert.AreEqual(battle + travel + 6 * H, army.Missing[0].EndMs, "окно -- 6 игровых часов");
Assert.AreEqual(0, army.ProcessMissing(battle + travel), "до окна никто не вернулся");
army.ProcessMissing(battle + travel + 6 * H - 1);
Assert.IsTrue(back.Count >= 2 && back.Count <= 4, "2-4 группы: " + back.Count);
int healthy = 0, wounded = 0;
foreach (var e in back) { healthy += e.Healthy; wounded += e.Wounded; }
Assert.AreEqual(20, healthy + wounded, "вернулась половина");
Assert.IsTrue(wounded >= 2 && wounded <= 12, "около 30% раненых: " + wounded);
Assert.AreEqual(wounded, army.WoundedTotal, "раненые -- в госпиталь");
army.ProcessMissing(battle + travel + 6 * H);
Assert.AreEqual(1, off.Count);
Assert.AreEqual(20, off[0].Count, "остальные списаны");
Assert.AreEqual(0, army.MissingTotal);
int groupsBefore = back.Count;
army.ProcessMissing(battle + travel + 100 * H);
Assert.AreEqual(groupsBefore, back.Count, "повторно никто не возвращается");
}
[Test]
public void Army_Missing_SaveRestore_NoDoubleReturn_AndPopulationReserve()
{
const long H = 5 * 60_000;
var army = MissingArmy();
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules());
GameServices.Register<IPopulation>(population);
int total = population.Total;
float food = population.FoodPerHour;
army.MarkMissing(1, new[] { 20, 0, 0, 0 }, 0, 0);
Assert.AreEqual(total, population.Total, "пропавшие занимают места в населении");
Assert.AreEqual(food - 20 * 0.2f, population.FoodPerHour, 0.001f, "но не едят");
army.ProcessMissing(3 * H); // часть групп вернулась
string saved = army.Capture();
int home = army.GetTroops(0), hurt = army.WoundedTotal, missing = army.MissingTotal;
var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 });
copy.Restore(saved);
Assert.AreEqual(saved, copy.Capture(), "пропавшие и их группы сохраняются");
Assert.AreEqual(missing, copy.MissingTotal);
copy.ProcessMissing(3 * H);
Assert.AreEqual(home, copy.GetTroops(0), "после загрузки уже вернувшиеся не возвращаются второй раз");
Assert.AreEqual(hurt, copy.WoundedTotal);
Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки");
}
}
@@ -239,6 +239,12 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
void HandleBattleResolved(MarchData march)
{
// Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами
if (Sum(march.missingUnits) > 0 && GameServices.TryGet(out IArmy army))
{
army.MarkMissing(march.id, march.missingUnits, march.arriveMs, Math.Max(0, march.returnMs - march.arriveMs));
army.ProcessMissing(NowMs);
}
MapNode target = World.GetNode(march.targetNodeId);
if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя");
else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
@@ -310,6 +316,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
IsGather = march.gather,
IsScout = march.scout,
Wounded = Sum(march.woundedUnits),
Missing = Sum(march.missingUnits),
TargetName = World.TargetName(march),
LootText = FormatLoot(march.loot)
});
@@ -37,6 +37,7 @@ namespace Bogatyri.World
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
public int[] survivorUnits; // выжившие по типам (известно после боя)
public int[] woundedUnits; // раненые по типам: идут домой с войском и ложатся в госпиталь (решения 31.10, п. 7)
public int[] missingUnits; // пропавшие без вести по типам: возвращаются сами, группами (решения 01.11, п. 5)
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
@@ -454,6 +455,7 @@ namespace Bogatyri.World
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
m.woundedUnits = HasUnits(m) ? outcome.AttackerWoundedUnits : null; // старый поход без состава -- раненые как погибшие
m.missingUnits = HasUnits(m) ? outcome.AttackerMissingUnits : null;
if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава
m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null));
m.defenders = target.garrison;
+2 -1
View File
@@ -39,11 +39,12 @@ public static class MarchReturnPopup
? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "<color=#7CD67C>ресурсы собраны</color>")
: e.TargetEmpty ? "лагерь уже был разбит, боя не было"
: e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded);
int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded - e.Missing);
string text = $"{target}: {result}\n" +
$"Вернулись {e.Survivors} из {e.Sent} воинов" + (dead > 0 ? $" (погибли {dead})" : "") + "\n" +
(e.Wounded > 0 ? $"Раненых {e.Wounded} -- в госпитале (окно «Войска»)\n" : "") +
(e.Missing > 0 ? $"Пропали без вести {e.Missing} -- часть может вернуться сама\n" : "") +
$"Добыча: {e.LootText}";
UIKit.Message("ВОЙСКО ВЕРНУЛОСЬ", text, "ОК", ShowNext);
@@ -799,9 +799,12 @@ public class WorldMapController : MonoBehaviour
// «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском
static string WoundedText(MarchData march)
{
int w = 0;
int w = 0, missing = 0;
if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u;
return w > 0 ? $" (ранены {w} -- придут в госпиталь)" : "";
if (march.missingUnits != null) foreach (var u in march.missingUnits) missing += u;
string text = w > 0 ? $"ранены {w} -- придут в госпиталь" : "";
if (missing > 0) text += (text.Length > 0 ? ", " : "") + $"пропали {missing}";
return text.Length > 0 ? $" ({text})" : "";
}
void HandleGatherStarted(MarchData march)