Files
MVP1/Assets/Scripts/Modules/Army/ArmyService.cs
T
AlexanderandClaude Opus 5.5 721881b844 Исправления по ревью 27.10.2026: безопасная отправка и возвращение войск
Армия (п. 2.1):
- ReturnFromMarch / ReturnUnits возвращают bool и зачисляют воинов только по известному походу и только один раз
  (раньше неизвестный поход добавлял выживших пехотой, ReturnUnits — без ограничения)
- вернуться может не больше, чем ушло
- TakeForMarch отклоняет повторный номер похода (раньше перезапись теряла воинов первого)
- загрузка сохранения и перенос старых данных пропускают дубликаты походов

Мир (п. 2.2):
- TryStartMarch проверяет TakeForMarch; при отказе поход откатывается (WorldSimulation.CancelMarch)
- если Армия не знает возвращающийся поход — ни воинов, ни добычи, предупреждение в консоли

Тесты (п. 2.1, 2.2, 2.4):
- неизвестный поход, повторное возвращение, повторный номер, откат похода
- крайние случаи боя: 1×1, 1×10, 10×1, 0×0, пустая сторона, бонусы −100% / −500% / +100000%, бой числом

Формула потерь не менялась. Найдено и записано в Battle.md как открытый вопрос:
одиночный проигравший не гибнет (потери округляются вниз) — правило округления решает команда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:38:01 +01:00

268 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
// МОДУЛЬ АРМИИ: единственное место, где хранятся войска. Документ: Docs/Modules/Army.md
// Без Unity: правила те же, что будут на сервере.
//
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
private class MarchUnits
{
public int marchId;
public int[] units = new int[Types];
}
private readonly int[] troops = new int[Types];
private readonly int[] garrison = new int[Types];
private readonly List<MarchUnits> marches = new List<MarchUnits>();
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
public bool RestoredFromSave { get; private set; }
// ---------------- Контракт IArmy ----------------
public int TypeCount => Types;
public int GetTroops(int type)
{
return type >= 0 && type < Types ? troops[type] : 0;
}
public int SoldiersAtHome
{
get { int n = 0; foreach (var t in troops) n += t; return n; }
}
public int TroopsInMarches
{
get
{
int n = 0;
foreach (var m in marches) foreach (var u in m.units) n += u;
return n;
}
}
public int GarrisonTotal
{
get { int n = 0; foreach (var g in garrison) n += g; return n; }
}
public int GetGarrison(int type)
{
return type >= 0 && type < Types ? garrison[type] : 0;
}
public int AvailableForMarch => SoldiersAtHome - GarrisonTotal;
// Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника
public int SetGarrison(int count)
{
count = Math.Max(0, Math.Min(count, SoldiersAtHome));
int[] units = count > 0 ? ArmyMath.TakeProportional(troops, count) : null;
for (int i = 0; i < Types; i++) garrison[i] = units != null ? units[i] : 0;
Changed();
return count;
}
public void AddTroops(int type, int count)
{
if (type < 0 || type >= Types || count <= 0) return;
troops[type] += count;
Changed();
}
public int LoseFraction(double fraction)
{
if (fraction <= 0) return 0;
if (fraction > 1) fraction = 1;
int lost = 0;
for (int i = 0; i < Types; i++)
{
int l = (int)Math.Floor(troops[i] * fraction);
troops[i] -= l;
lost += l;
}
ClampGarrison();
if (lost > 0) Changed();
return lost;
}
public bool TakeForMarch(int marchId, int count)
{
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
if (marches.Exists(m => m.marchId == marchId)) return false;
// В поход -- только свободные воины, гарнизон остаётся в городе
var free = new int[Types];
for (int i = 0; i < Types; i++) free[i] = troops[i] - garrison[i];
int[] taken = ArmyMath.TakeProportional(free, count);
if (taken == null) return false;
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
marches.Add(new MarchUnits { marchId = marchId, units = taken });
Changed();
return true;
}
// Воины возвращаются только из известного похода, и только один раз: запись похода удаляется.
// Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты
// появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy.
public bool ReturnFromMarch(int marchId, int survivors)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
int sent = 0;
foreach (var u in entry.units) sent += u;
int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
Changed();
return true;
}
public int[] GetMarchUnits(int marchId)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
return entry != null ? (int[])entry.units.Clone() : null;
}
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
public bool ReturnUnits(int marchId, int[] survivorUnits)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i]));
marches.Remove(entry);
Changed();
return true;
}
void ClampGarrison()
{
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
}
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
}
// ---------------- Перенос из старого progress.json ----------------
// Войска раньше хранил GameProgress (troops) и мост ArmyBridge (sashaMarchUnits).
// Вызывается один раз, если в save.json ещё нет секции "army".
public void ImportLegacy(int[] legacyTroops, IEnumerable<KeyValuePair<int, int[]>> legacyMarches)
{
if (legacyTroops != null)
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
Array.Clear(garrison, 0, Types);
marches.Clear();
if (legacyMarches != null)
{
foreach (var m in legacyMarches)
{
if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись
var units = new int[Types];
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
marches.Add(new MarchUnits { marchId = m.Key, units = units });
}
}
Changed();
}
// ---------------- Сохранение (секция "army") ----------------
// Модуль без Unity, поэтому формат -- простые строки:
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
public string SaveKey => "army";
public string Capture()
{
var sb = new StringBuilder();
sb.Append("troops=").Append(Join(troops)).Append('\n');
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
foreach (var m in marches)
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
return sb.ToString();
}
public void Restore(string data)
{
Array.Clear(troops, 0, Types);
Array.Clear(garrison, 0, Types);
marches.Clear();
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
{
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("troops="))
{
int[] t = Parse(line.Substring("troops=".Length));
Array.Copy(t, troops, Types);
}
else if (line.StartsWith("garrison="))
{
int[] g = Parse(line.Substring("garrison=".Length));
Array.Copy(g, garrison, Types);
}
else if (line.StartsWith("march="))
{
string[] parts = line.Substring("march=".Length).Split(':');
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
}
}
}
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
Changed();
}
// Новая игра: войск нет, гарнизона и походов нет
public void ResetState()
{
Array.Clear(troops, 0, Types);
Array.Clear(garrison, 0, Types);
marches.Clear();
Changed();
}
static string Join(int[] values)
{
var parts = new string[values.Length];
for (int i = 0; i < values.Length; i++) parts[i] = values[i].ToString(CultureInfo.InvariantCulture);
return string.Join(",", parts);
}
static int[] Parse(string csv)
{
var result = new int[Types];
string[] parts = csv.Split(',');
for (int i = 0; i < Types && i < parts.Length; i++)
{
if (int.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
result[i] = Math.Max(0, v);
}
return result;
}
}