champions-of-luneraria/Assets/Luneraria/Net/LuneWire.cs
Martin Stickelbroeck fb5c92dd37 S3 Neutrallager und Fog of War mit Spähern (LoL, Dota 2)
Erweiterte Regeln: in jeder dritten Runde ein neutraler Wächter in jeder
Lanemitte; wer beim Fall mehr Einheiten (dann mehr Leben) in 8 m hat, nimmt
das Lager: Glutsegen (+10 % Schaden), Mondschild (Schild 10 %), Beute
(50 Gold je Spieler). Fog of War (LuneFog, nur Darstellung): Gegner sichtbar
in 9 m um eigene Einheiten, 10 m um Gebäude, 12 m um Späher; Späher (40 Gold,
3 Runden, Teamwirkung) zeigen in der Planung die gegnerische Armee ihrer Lane.
HUD: Teampanel SPÄHER und LAGER, Lager-Sechseck, Nebel in Feld, Minimap,
Namensschildern und Lane-Zählung.
Kern 597/597 (s3 31), EditMode 33/33, PlayMode 18/18, Verify 718/0,
Selbsttest 30/30, Golden 0, Fingerabdruck unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:30:57 +02:00

879 lines
32 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Luneraria.Core;
namespace Luneraria.Net
{
/// <summary>
/// Compact cursor codec for the session messages (the Godot RPC payloads were Variants).
/// One message is one UTF-8 line: kind TAB payload. Compound values write their fields in a
/// fixed order; the reader consumes exactly as many tokens as the shape needs, so nesting
/// never collides with separators. No engine, no JSON library.
/// </summary>
public static class LuneWire
{
static readonly NumberFormatInfo Nfi = CultureInfo.InvariantCulture.NumberFormat;
const char Sep = '\x1f';
// ---------------------------------------------------------------- encode
public static string Encode(string kind, object payload)
{
var text = new StringBuilder(kind);
text.Append('\t');
var parts = payload as object[];
if (parts != null)
{
text.Append('i');
text.Append(parts.Length.ToString(Nfi));
for (int i = 0; i < parts.Length; i++)
{
text.Append(Sep);
WriteValue(text, parts[i]);
}
}
else
{
text.Append('i');
text.Append('1');
text.Append(Sep);
WriteValue(text, payload);
}
return text.ToString();
}
public static byte[] EncodeFrame(int serial, double elapsed, float[] units, float[] buildingHp)
{
using (var stream = new MemoryStream())
using (var writer = new BinaryWriter(stream))
{
writer.Write(serial);
writer.Write(elapsed);
writer.Write(units != null ? units.Length : 0);
if (units != null)
{
for (int i = 0; i < units.Length; i++)
writer.Write(units[i]);
}
writer.Write(buildingHp != null ? buildingHp.Length : 0);
if (buildingHp != null)
{
for (int i = 0; i < buildingHp.Length; i++)
writer.Write(buildingHp[i]);
}
return stream.ToArray();
}
}
static void WriteValue(StringBuilder text, object value)
{
if (value == null)
{
text.Append('~');
return;
}
if (value is string)
{
text.Append('s');
text.Append(Escape((string)value));
return;
}
if (value is bool)
{
text.Append((bool)value ? 'B' : 'b');
return;
}
if (value is int || value is long)
{
text.Append('i');
text.Append(Convert.ToInt64(value).ToString(Nfi));
return;
}
if (value is float || value is double)
{
text.Append('f');
text.Append(Convert.ToDouble(value).ToString("R", Nfi));
return;
}
if (value is float[])
{
float[] array = (float[])value;
text.Append('a');
text.Append(array.Length.ToString(Nfi));
for (int i = 0; i < array.Length; i++)
{
text.Append(',');
text.Append(array[i].ToString("R", Nfi));
}
return;
}
if (value is Phase)
{
text.Append('i');
text.Append(((int)(Phase)value).ToString(Nfi));
return;
}
if (value is Command)
{
text.Append('C');
WriteCommand(text, (Command)value);
return;
}
if (value is MatchOptions)
{
text.Append('O');
WriteOptions(text, (MatchOptions)value);
return;
}
if (value is LobbyEntry)
{
text.Append('L');
WriteLobby(text, (LobbyEntry)value);
return;
}
if (value is List<LobbyEntry>)
{
text.Append('l');
WriteList(text, (List<LobbyEntry>)value, WriteLobby);
return;
}
if (value is NetState)
{
text.Append('S');
WriteState(text, (NetState)value);
return;
}
if (value is NetUnitRecord)
{
text.Append('U');
WriteUnit(text, (NetUnitRecord)value);
return;
}
if (value is List<NetUnitRecord>)
{
text.Append('u');
WriteList(text, (List<NetUnitRecord>)value, WriteUnit);
return;
}
throw new InvalidDataException("LuneWire cannot encode " + value.GetType().Name);
}
static void WriteList<T>(StringBuilder text, List<T> list, Action<StringBuilder, T> write)
{
// Count rides in the tag body ("l2"); each write() starts with Sep before its first field.
text.Append(list.Count.ToString(Nfi));
for (int i = 0; i < list.Count; i++)
write(text, list[i]);
}
static void WriteCommand(StringBuilder text, Command command)
{
Str(text, command.Type ?? "");
Str(text, command.TypeId);
Str(text, command.Lane);
Str(text, command.Uid);
Str(text, command.HeroId);
Str(text, command.ItemId);
Str(text, command.ChampionId);
Int(text, command.Tier);
Int(text, command.Choice);
}
static void WriteOptions(StringBuilder text, MatchOptions options)
{
Int(text, options.Seed);
Int(text, options.HumanSlot);
Str(text, options.Bunds != null ? string.Join("\x1d", options.Bunds) : "");
Str(text, options.Profiles != null ? string.Join("\x1d", options.Profiles) : "");
Int(text, options.Extended ? 1 : 0);
}
static void WriteLobby(StringBuilder text, LobbyEntry entry)
{
Int(text, entry.Peer);
Int(text, entry.Slot);
Str(text, entry.Name ?? "");
Int(text, entry.Ready ? 1 : 0);
Int(text, entry.Bot ? 1 : 0);
}
static void WriteState(StringBuilder text, NetState state)
{
Int(text, (int)state.Phase);
Int(text, state.Round);
Int(text, state.Winner);
Str(text, state.EndNote ?? "");
var hp = state.BuildingHp ?? new Dictionary<string, double>();
Int(text, hp.Count);
foreach (KeyValuePair<string, double> pair in hp)
{
Str(text, pair.Key);
Num(text, pair.Value);
}
BossState boss = state.Boss ?? new BossState();
Int(text, boss.NextAvailableRound);
Int(text, boss.SettledRound);
Int(text, boss.Claim != null ? 1 : 0);
if (boss.Claim != null)
{
Int(text, boss.Claim.Team);
Int(text, boss.Claim.WonRound);
Int(text, boss.Claim.DeployRound);
Int(text, boss.Claim.ChooserSlot);
Str(text, boss.Claim.Lane ?? "");
}
Int(text, boss.Blessing != null && boss.Blessing.Length > 0 ? boss.Blessing[0] : 0);
Int(text, boss.Blessing != null && boss.Blessing.Length > 1 ? boss.Blessing[1] : 0);
for (int team = 0; team < 2; team++)
{
Int(text, boss.GlyphReady != null && boss.GlyphReady.Length > team ? boss.GlyphReady[team] : 1);
Int(text, boss.CoreLevel != null && boss.CoreLevel.Length > team ? boss.CoreLevel[team] : 0);
}
for (int i = 0; i < 6; i++)
Int(text, boss.CampBuff != null && boss.CampBuff.Length > i ? boss.CampBuff[i] : 0);
for (int i = 0; i < 6; i++)
Int(text, boss.Wards != null && boss.Wards.Length > i ? boss.Wards[i] : 0);
var cores = state.DestroyedCores ?? new List<int>();
Int(text, cores.Count);
foreach (int core in cores)
Int(text, core);
Settlement settle = state.LastSettlement;
Int(text, settle != null ? 1 : 0);
if (settle != null)
{
Int(text, settle.Round);
Int(text, settle.Income);
Str(text, settle.EndReason ?? "");
Num(text, settle.CombatTime);
Str(text, settle.Boss ?? "none");
int gains = settle.Gains != null ? settle.Gains.Length : 0;
Int(text, gains);
for (int i = 0; i < gains; i++)
Int(text, settle.Gains[i]);
var lost = settle.BuildingsLost ?? new List<string>();
Int(text, lost.Count);
foreach (string id in lost)
Str(text, id);
}
var players = state.Players ?? new List<NetPlayerRow>();
Int(text, players.Count);
foreach (NetPlayerRow row in players)
WritePlayer(text, row);
}
static void WritePlayer(StringBuilder text, NetPlayerRow row)
{
Int(text, row.Slot);
Str(text, row.Name ?? "");
Int(text, row.IsHuman ? 1 : 0);
Int(text, row.Gold);
Int(text, row.Ready ? 1 : 0);
Int(text, row.Earned);
Int(text, row.Spent);
Int(text, row.NextSeq);
Int(text, row.Undo);
Int(text, row.IncomeBonus);
WriteRosterList(text, row.Roster);
Int(text, row.Hero != null ? 1 : 0);
if (row.Hero != null)
WriteHero(text, row.Hero);
WriteBookingList(text, row.Bookings);
Int(text, row.Draft != null ? 1 : 0);
if (row.Draft != null)
{
Draft draft = row.Draft;
Int(text, draft.Gold);
Int(text, draft.NextSeq);
Str(text, draft.BossLane ?? "");
Str(text, draft.GlyphLane ?? "");
Int(text, draft.CoreInvest);
Str(text, string.Join("\x1d", draft.WardLanes));
WriteRosterList(text, draft.Roster);
Int(text, draft.Hero != null ? 1 : 0);
if (draft.Hero != null)
WriteHero(text, draft.Hero);
WriteBookingList(text, draft.NewBookings);
}
}
static void WriteRosterList(StringBuilder text, List<RosterEntry> list)
{
var rows = list ?? new List<RosterEntry>();
Int(text, rows.Count);
foreach (RosterEntry entry in rows)
{
Str(text, entry.Uid ?? "");
Int(text, entry.Seq);
Str(text, entry.TypeId ?? "");
Str(text, entry.Lane ?? "");
Int(text, entry.Invested);
Int(text, entry.Fresh ? 1 : 0);
}
}
static void WriteBookingList(StringBuilder text, List<Booking> list)
{
var rows = list ?? new List<Booking>();
Int(text, rows.Count);
foreach (Booking booking in rows)
{
Str(text, booking.Uid ?? "");
Int(text, booking.Seq);
Str(text, booking.ChampionId ?? "");
Str(text, booking.Lane ?? "");
Int(text, booking.Cost);
Int(text, booking.DeployRound);
}
}
static void WriteHero(StringBuilder text, HeroState hero)
{
Str(text, hero.Uid ?? "");
Int(text, hero.Seq);
Str(text, hero.HeroId ?? "");
Str(text, hero.Lane ?? "");
Int(text, hero.Xp);
Int(text, hero.Invested);
Int(text, hero.Fresh ? 1 : 0);
Str(text, hero.Items != null ? string.Join("\x1d", hero.Items) : "");
Str(text, hero.Talents != null ? string.Join("\x1d", hero.Talents) : "");
Str(text, hero.FreshItems != null ? string.Join("\x1d", hero.FreshItems) : "");
Str(text, hero.Ultimate ?? "");
Str(text, hero.Skin ?? "");
Int(text, hero.Exhausted ? 1 : 0);
}
static void WriteUnit(StringBuilder text, NetUnitRecord unit)
{
Int(text, unit.Id);
Str(text, unit.Kind ?? "");
Int(text, unit.Team);
Int(text, unit.TeamIndex);
Int(text, unit.OwnerSlot);
Str(text, unit.Uid ?? "");
Str(text, unit.Lane ?? "");
Str(text, unit.Zone ?? "");
Num(text, unit.X);
Num(text, unit.Y);
Num(text, unit.Facing);
Num(text, unit.Hp);
Num(text, unit.MaxHp);
Int(text, unit.Alive ? 1 : 0);
UnitDef def = unit.Def ?? new UnitDef();
Str(text, def.Id ?? "");
Str(text, def.DisplayName ?? "");
Int(text, def.Cost);
Str(text, def.UpgradeId ?? "");
Num(text, def.MaxHp);
Num(text, def.Damage);
Int(text, def.AttackType);
Int(text, def.ArmorType);
Num(text, def.AttackRange);
Num(text, def.Speed);
Str(text, def.Ability ?? "");
Int(text, def.LeakValue);
Int(text, def.Bounty);
Str(text, def.AbilityDescription ?? "");
Int(text, def.IsBoss ? 1 : 0);
Int(text, def.IncomeGain);
Str(text, def.Role ?? "");
Str(text, def.HeroId ?? "");
Int(text, def.Level);
Num(text, def.ViewScale);
Num(text, def.Regen);
Num(text, def.ItemSplash);
Num(text, def.DamageBonus);
Num(text, def.ReductionBonus);
Num(text, def.AttackSpeedBonus);
Str(text, def.ChampionId ?? "");
Str(text, def.Priority ?? "");
Num(text, def.SiegeFactor);
Num(text, def.HpFactor);
Str(text, def.Tier ?? "");
Str(text, def.Talents != null ? string.Join("\x1d", def.Talents) : "");
Str(text, def.UltimateRule ?? "");
Str(text, def.Skin ?? "");
}
static void Str(StringBuilder text, string value)
{
text.Append(Sep);
if (value == null)
text.Append('~');
else
{
text.Append('s');
text.Append(Escape(value));
}
}
static void Int(StringBuilder text, int? value)
{
text.Append(Sep);
if (value == null)
text.Append('~');
else
{
text.Append('i');
text.Append(value.Value.ToString(Nfi));
}
}
static void Num(StringBuilder text, double value)
{
text.Append(Sep);
text.Append('f');
text.Append(value.ToString("R", Nfi));
}
static string Escape(string value)
{
return value.Replace("\\", "\\\\").Replace("\n", "\\n").Replace("\t", "\\t")
.Replace("\x1d", "\\r").Replace("\x1e", "\\e").Replace("\x1f", "\\f");
}
static string Unescape(string value)
{
// Order matters: the two-character escapes first, the literal backslash last.
return value.Replace("\\f", "\x1f").Replace("\\e", "\x1e").Replace("\\r", "\x1d")
.Replace("\\t", "\t").Replace("\\n", "\n").Replace("\\\\", "\\");
}
// ---------------------------------------------------------------- decode
public static void Decode(string line, out string kind, out object payload)
{
int tab = line.IndexOf('\t');
kind = tab < 0 ? line : line.Substring(0, tab);
string body = tab < 0 ? "" : line.Substring(tab + 1);
if (body.Length == 0)
{
payload = null;
return;
}
var reader = new Reader(body);
// Leading token is the top-level value count (1 = single payload, n = object[]).
int count = reader.Integer(1);
if (count <= 1)
{
payload = reader.Value();
return;
}
var parts = new object[count];
for (int i = 0; i < count; i++)
parts[i] = reader.Value();
payload = parts;
}
public static void DecodeFrame(byte[] buffer, out int serial, out double elapsed, out float[] units, out float[] buildingHp)
{
using (var stream = new MemoryStream(buffer))
using (var reader = new BinaryReader(stream))
{
serial = reader.ReadInt32();
elapsed = reader.ReadDouble();
int unitCount = reader.ReadInt32();
units = new float[unitCount];
for (int i = 0; i < unitCount; i++)
units[i] = reader.ReadSingle();
int hpCount = reader.ReadInt32();
buildingHp = new float[hpCount];
for (int i = 0; i < hpCount; i++)
buildingHp[i] = reader.ReadSingle();
}
}
/// <summary>Token cursor over a Sep-separated payload. Tagged values: ~ null, s string, i int, f float, B/b bool, a float[], C/O/L/l/S/U/u compounds.</summary>
sealed class Reader
{
readonly string[] _tokens;
int _at;
public Reader(string body)
{
_tokens = body.Split(Sep);
_at = 0;
}
public int Remaining { get { return _tokens.Length - _at; } }
string Take()
{
if (_at >= _tokens.Length)
throw new InvalidDataException("LuneWire: payload ends early");
return _tokens[_at++];
}
public object Value()
{
string token = Take();
if (token.Length == 0 || token[0] == '~')
return null;
char tag = token[0];
string body = token.Substring(1);
switch (tag)
{
case 's':
return Unescape(body);
case 'B':
return true;
case 'b':
return false;
case 'i':
return int.Parse(body, Nfi);
case 'f':
return double.Parse(body, Nfi);
case 'a':
{
string[] parts = body.Split(',');
var data = new float[Math.Max(0, parts.Length - 1)];
for (int i = 1; i < parts.Length; i++)
data[i - 1] = float.Parse(parts[i], Nfi);
return data;
}
case 'C':
return ReadCommand();
case 'O':
return ReadOptions();
case 'L':
return ReadLobby();
case 'l':
return ReadLobbyList(body);
case 'S':
return ReadState();
case 'U':
return ReadUnit();
case 'u':
return ReadUnitList(body);
default:
throw new InvalidDataException("LuneWire tag " + tag);
}
}
string Text()
{
object value = Value();
return value as string;
}
public int Integer(int fallback = 0)
{
object value = Value();
if (value == null)
return fallback;
return Convert.ToInt32(value);
}
double Number(double fallback = 0.0)
{
object value = Value();
if (value == null)
return fallback;
return Convert.ToDouble(value);
}
bool Flag()
{
object value = Value();
if (value == null)
return false;
if (value is bool)
return (bool)value;
return Convert.ToInt32(value) != 0;
}
Command ReadCommand()
{
return new Command
{
Type = Text() ?? "",
TypeId = Text(),
Lane = Text(),
Uid = Text(),
HeroId = Text(),
ItemId = Text(),
ChampionId = Text(),
Tier = ReadNullableInt(),
Choice = ReadNullableInt()
};
}
int? ReadNullableInt()
{
object value = Value();
if (value == null)
return null;
return Convert.ToInt32(value);
}
MatchOptions ReadOptions()
{
var options = new MatchOptions { Seed = Integer(1), HumanSlot = Integer(1) };
string bunds = Text();
if (!string.IsNullOrEmpty(bunds))
options.Bunds.AddRange(bunds.Split('\x1d'));
string profiles = Text();
if (!string.IsNullOrEmpty(profiles))
options.Profiles.AddRange(profiles.Split('\x1d'));
options.Extended = Integer() == 1;
return options;
}
LobbyEntry ReadLobby()
{
return new LobbyEntry
{
Peer = Integer(),
Slot = Integer(),
Name = Text() ?? "",
Ready = Flag(),
Bot = Flag()
};
}
List<LobbyEntry> ReadLobbyList(string body)
{
int count = body.Length > 0 ? int.Parse(body, Nfi) : 0;
var list = new List<LobbyEntry>(count);
for (int i = 0; i < count; i++)
list.Add(ReadLobby());
return list;
}
NetState ReadState()
{
var state = new NetState
{
Phase = (Phase)Integer(),
Round = Integer(),
Winner = Integer(),
EndNote = Text() ?? ""
};
int hpCount = Integer();
for (int i = 0; i < hpCount; i++)
{
string id = Text() ?? "";
state.BuildingHp[id] = Number();
}
state.Boss = new BossState { NextAvailableRound = Integer(), SettledRound = Integer() };
if (Flag())
{
state.Boss.Claim = new BossClaim
{
Team = Integer(),
WonRound = Integer(),
DeployRound = Integer(),
ChooserSlot = Integer(),
Lane = Text() ?? ""
};
}
state.Boss.Blessing = new[] { Integer(), Integer() };
for (int team = 0; team < 2; team++)
{
state.Boss.GlyphReady[team] = Integer(1);
state.Boss.CoreLevel[team] = Integer();
}
for (int i = 0; i < 6; i++)
state.Boss.CampBuff[i] = Integer();
for (int i = 0; i < 6; i++)
state.Boss.Wards[i] = Integer();
int coreCount = Integer();
for (int i = 0; i < coreCount; i++)
state.DestroyedCores.Add(Integer());
if (Flag())
{
var settle = new Settlement
{
Round = Integer(),
Income = Integer(),
EndReason = Text() ?? "",
CombatTime = Number(),
Boss = Text() ?? "none"
};
int gains = Integer();
for (int i = 0; i < gains && i < settle.Gains.Length; i++)
settle.Gains[i] = Integer();
int lost = Integer();
for (int i = 0; i < lost; i++)
settle.BuildingsLost.Add(Text() ?? "");
state.LastSettlement = settle;
}
int players = Integer();
for (int i = 0; i < players; i++)
state.Players.Add(ReadPlayer());
return state;
}
NetPlayerRow ReadPlayer()
{
var row = new NetPlayerRow
{
Slot = Integer(),
Name = Text() ?? "",
IsHuman = Flag(),
Gold = Integer(),
Ready = Flag(),
Earned = Integer(),
Spent = Integer(),
NextSeq = Integer(),
Undo = Integer(),
IncomeBonus = Integer()
};
row.Roster = ReadRosterList();
if (Flag())
row.Hero = ReadHero();
row.Bookings = ReadBookingList();
if (Flag())
{
var draft = new Draft { Gold = Integer(), NextSeq = Integer(), BossLane = Text() ?? "", GlyphLane = Text() ?? "", CoreInvest = Integer() };
AddSplit(draft.WardLanes, Text());
draft.Roster = ReadRosterList();
if (Flag())
draft.Hero = ReadHero();
draft.NewBookings = ReadBookingList();
row.Draft = draft;
}
return row;
}
List<RosterEntry> ReadRosterList()
{
int count = Integer();
var list = new List<RosterEntry>(count);
for (int i = 0; i < count; i++)
{
list.Add(new RosterEntry
{
Uid = Text() ?? "",
Seq = Integer(),
TypeId = Text() ?? "",
Lane = Text() ?? "",
Invested = Integer(),
Fresh = Flag()
});
}
return list;
}
List<Booking> ReadBookingList()
{
int count = Integer();
var list = new List<Booking>(count);
for (int i = 0; i < count; i++)
{
list.Add(new Booking
{
Uid = Text() ?? "",
Seq = Integer(),
ChampionId = Text() ?? "",
Lane = Text() ?? "",
Cost = Integer(),
DeployRound = Integer()
});
}
return list;
}
HeroState ReadHero()
{
var hero = new HeroState
{
Uid = Text() ?? "",
Seq = Integer(),
HeroId = Text() ?? "",
Lane = Text() ?? "",
Xp = Integer(),
Invested = Integer(),
Fresh = Flag()
};
AddSplit(hero.Items, Text());
AddSplit(hero.Talents, Text());
AddSplit(hero.FreshItems, Text());
hero.Ultimate = Text() ?? "";
hero.Skin = Text() ?? "";
hero.Exhausted = Integer() == 1;
return hero;
}
static void AddSplit(List<string> target, string value)
{
target.Clear();
if (string.IsNullOrEmpty(value))
return;
foreach (string part in value.Split('\x1d'))
{
if (part.Length > 0)
target.Add(part);
}
}
NetUnitRecord ReadUnit()
{
var unit = new NetUnitRecord
{
Id = Integer(),
Kind = Text() ?? "",
Team = Integer(),
TeamIndex = Integer(),
OwnerSlot = Integer(),
Uid = Text() ?? "",
Lane = Text() ?? "",
Zone = Text() ?? "",
X = Number(),
Y = Number(),
Facing = Number(),
Hp = Number(),
MaxHp = Number(),
Alive = Flag(),
Def = new UnitDef
{
Id = Text() ?? "",
DisplayName = Text() ?? "",
Cost = Integer(),
UpgradeId = Text() ?? "",
MaxHp = Number(1.0),
Damage = Number(),
AttackType = Integer(),
ArmorType = Integer(),
AttackRange = Number(1.0),
Speed = Number(2.0),
Ability = Text() ?? "",
LeakValue = Integer(),
Bounty = Integer(),
AbilityDescription = Text() ?? "",
IsBoss = Flag(),
IncomeGain = Integer(),
Role = Text() ?? "",
HeroId = Text() ?? "",
Level = Integer(1),
ViewScale = Number(1.0),
Regen = Number(),
ItemSplash = Number(),
DamageBonus = Number(),
ReductionBonus = Number(),
AttackSpeedBonus = Number(),
ChampionId = Text() ?? "",
Priority = Text() ?? "",
SiegeFactor = Number(1.0),
HpFactor = Number(1.0),
Tier = Text() ?? ""
}
};
AddSplit(unit.Def.Talents, Text());
unit.Def.UltimateRule = Text() ?? "";
unit.Def.Skin = Text() ?? "";
return unit;
}
List<NetUnitRecord> ReadUnitList(string body)
{
int count = body.Length > 0 ? int.Parse(body, Nfi) : 0;
var list = new List<NetUnitRecord>(count);
for (int i = 0; i < count; i++)
list.Add(ReadUnit());
return list;
}
}
}
}