Files
MVP1/Assets/Scripts/Modules/Army/ArmyService.cs
T
AlexanderandClaude Opus 5.5 e8b17a7efa Скорости маршей по типам войск и слоты похода (регламент 31.10)
Мир (Bogatyri.World.Simulation):
- MarchSpeedRules: скорость по типам — пехота 8, лучники 9, кавалерия 12, осадные 4 клетки
  за реальный час (ТЕСТ); марш идёт в реальном времени
- смешанное войско — по самому медленному типу (настройка mixedUsesSlowest: средняя по числу воинов);
  правило для одной армии в регламенте не задано — вопрос команде
- WorldSimulation.TravelTimeMs, AssignUnits: время прихода пересчитывается по составу похода;
  обратный путь — по составу выживших
- MarchRules.GetTravelTimeMs — для похода без состава (как пехота)

Армия (Bogatyri.Army):
- слоты похода: IArmy.MarchSlots (база 2, ТЕСТ), ActiveMarches; TakeForMarch отказывает при занятых слотах;
  походы из старых сохранений сверх лимита дойдут, новых не будет

Таблица MarchConfig (Assets/Resources/MarchConfig.asset): скорости, режим смешанного войска, база слотов.
Карта мира: путь в окне атаки — по составу при выбранном числе воинов, «Все слоты похода заняты»,
верхняя строка «отрядов: 1 из 2».

Тесты: скорости по типам, смешанное войско, обратный путь, слоты; старые тесты мира переведены
на часовые марши. Все 49 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:55 +01:00

281 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;
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
private readonly int baseMarchSlots;
public ArmyService(int baseMarchSlots = 2)
{
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
}
public int MarchSlots => baseMarchSlots;
public int ActiveMarches => marches.Count;
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;
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
if (marches.Count >= MarchSlots) 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;
}
}