champions-of-luneraria/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs
Martin Stickelbroeck 769e622952 U4 Netzwerk: LuneSession, TCP, Host/Join, 43+12 Prüfungen
Der M4-Umfang ist als Protokoll 4 portiert, serverautoritativ. Der Host
besitzt LuneMatch und rechnet jeden Kampf; Clients senden nur Absichten
und zeigen den Kampf aus Host-Momentaufnahmen.

- LuneSession (Core/Net): Rollen offline/Host/Gast, sechs Sitze, Lobby,
  Hallo mit Protokollprüfung und Namenskürzung, Beitritt und Wieder-
  einstieg über den Anzeigenamen, private Planung mit Spoof-Schutz,
  Bereit ist verbindlich, Bots planen einmal je Runde, Disconnect gibt
  den Platz an einen Bot (nie Kernverlust), Übernahme verwirft den
  unbestätigten Bot-Entwurf, Revanche hält die Plätze, Hostverlust
  meldet und führt ins Menü zurück.
- Kampfsynchronisation: Beginn mit Einheitenätzen und Gebäude-HP, alle
  0,05 s neue Einheiten und ein Frame (13 Werte je Einheit), späte
  Frames über die Serial verworfen, Gast interpoliert (frame_alpha).
- Transport: LuneLoopback (In-Prozess) für die Tests, LuneTcp + LuneWire
  (Cursor-Codec) für Echtverbindungen statt ENet; ein Socket je Gast.
- Oberfläche: LuneMainMenu (Offline/Hosten/Beitreten, Bund, Lane, Name,
  Adresse, Port) als Startbildschirm, LuneNetPlay als Schale wie
  lune_main.gd. LuneGame leitet Befehle, Rückgängig und Bereit im
  Netzspiel über die Sitzung; Tempo und Pause bestimmt der Host.
- Tests: NetSuite 43/43 (Port von test_lune_net.gd), NetRoundsSuite
  12/12 (Port von net_rounds.py: 5 Runden, Prüfsummen gleich, Gast
  rechnet 0 Schritte, Drop und Wiedereinstieg auf denselben Sitz),
  LuneWireTests Round-trip. EditMode 29/29, PlayMode 13/13, Kerntests
  457/457. Fingerabdrücke der Bot-Partien unverändert.
- Dokumentation: U4-NETWORK (Abnahme, Grenzen), PORT-QA (U4), PORT-STATUS
  (U4 fertig, U5 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES.

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

190 lines
9.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Multi-round loopback with two sessions (port of tools/luneraria/net_rounds.py, 12 checks).
/// Host and guest play five rounds through the real LuneSession; after every settlement the
/// public checksums must agree. Guest A leaves during the combat of round 2, the bot takes
/// over, guest B returns with the same display name and must get the same seat back. The
/// guest never runs a combat step and receives host snapshots. This is a local in-process
/// loopback, not a six-player internet match.
/// </summary>
public sealed class NetRoundsSuite : CheckSuite
{
public override string Name { get { return "net_rounds"; } }
const int Rounds = 5;
const int DropRound = 2;
/// <summary>Host combat steps per tick (net_pair.gd steps_per_frame); snapshots still flow.</summary>
const int StepsPerTick = 8;
sealed class CombatLog
{
public int Round;
public int Frames;
public int LocalSteps;
}
readonly Dictionary<int, string> _hostSums = new Dictionary<int, string>();
readonly Dictionary<int, string> _guestSums = new Dictionary<int, string>();
readonly List<CombatLog> _combats = new List<CombatLog>();
readonly List<int> _seatsA = new List<int>();
readonly List<int> _seatsB = new List<int>();
readonly List<string> _hostMessages = new List<string>();
readonly List<string> _errors = new List<string>();
protected override void RunChecks()
{
var hub = new LuneLoopback();
LuneSession host = hub.Attach(1, new LuneSession());
host.SessionMessage += (text, isError) =>
{
if (isError)
_errors.Add("host: " + text);
else
_hostMessages.Add(text);
};
var options = new MatchOptions
{
Seed = 42,
HumanSlot = 0,
Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" }
};
if (!host.HostNetwork(options, "Host"))
{
Check(false, "host starts: " + host.LastError);
return;
}
const int peerA = 10;
const int peerB = 11;
LuneSession guest = Join(hub, peerA, "Gast", _seatsA, _errors);
bool spoofRejected = !guest.HostCommand(1, 5, Command.Buy("w01")).Ok;
bool dropped = false;
int hostPlannedRound = 0;
int guestPlannedRound = 0;
int framesAtSegment = 0;
var current = new CombatLog();
for (int guard = 0; guard < 8000 && host.MatchState.Phase != Phase.Over && host.MatchState.RoundNumber <= Rounds + 1; guard++)
{
LuneMatch match = host.MatchState;
if (match.Phase == Phase.Planning)
{
if (match.RoundNumber > Rounds)
break;
host.PlanBotSeats();
PlanOnce(host, ref hostPlannedRound, host.LocalSlot, true);
if (guest != null && guest.MatchState != null && guest.LocalSlot >= 0)
PlanOnce(guest, ref guestPlannedRound, guest.LocalSlot, false);
}
else if (match.Phase == Phase.Combat)
{
if (current.Round != match.RoundNumber)
{
current = new CombatLog { Round = match.RoundNumber };
_combats.Add(current);
framesAtSegment = guest != null ? guest.FramesReceived : 0;
}
for (int step = 0; step < StepsPerTick && match.Phase == Phase.Combat; step++)
match.StepCombat();
host.HostTick(LuneNet.FrameInterval);
if (guest != null && guest.MatchState != null && guest.MatchState.Combat != null)
{
current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment);
current.LocalSteps = Math.Max(current.LocalSteps, guest.MatchState.Combat.Steps);
}
// Leave on a snapshot of the combat of DropRound: short combats may send only a few.
if (!dropped && hub.Has(peerA) && match.RoundNumber == DropRound && current.Frames > 0)
{
if (guest != null)
current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment);
dropped = true;
hub.Detach(peerA);
// Guest B returns with the same display name while the seat is bot-held.
guest = Join(hub, peerB, "Gast", _seatsB, _errors);
guestPlannedRound = 0;
framesAtSegment = guest.FramesReceived;
}
}
// Settlement: the phase left combat in this or a previous step.
if (current.Round != 0 && match.Phase != Phase.Combat)
{
if (guest != null && guest.MatchState != null && guest.MatchState.Combat != null)
{
current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment);
current.LocalSteps = Math.Max(current.LocalSteps, guest.MatchState.Combat.Steps);
}
host.HostAfterCombat();
NoteSums(host, guest, current.Round);
current = new CombatLog();
}
}
if (current.Round != 0)
{
host.HostAfterCombat();
NoteSums(host, guest, current.Round);
}
// ---------------------------------------------------------------- 12 checks
List<int> compared = _hostSums.Keys.Where(r => _guestSums.ContainsKey(r)).OrderBy(r => r).ToList();
List<int> mismatches = compared.Where(r => _hostSums[r] != _guestSums[r]).ToList();
Check(host.MatchState != null, "host_ok");
Check(dropped, "guest_a_ok (left during the combat)");
Check(_seatsB.Count > 0, "guest_b_ok");
Check(spoofRejected, "spoof_rejected");
Check(compared.Count >= Rounds - 1, "rounds_compared " + compared.Count + " of " + Rounds + " (" + string.Join(",", compared) + ")");
Check(mismatches.Count == 0 && compared.Count > 0, "checksums_equal " + string.Join(",", mismatches));
Check(_combats.Count > 0 && _combats.All(c => c.LocalSteps == 0), "client_never_simulates " + string.Join(",", _combats.Select(c => c.Round + ":" + c.LocalSteps)));
Check(_combats.Count > 0 && _combats.All(c => c.Frames > 0), "client_receives_frames " + string.Join(",", _combats.Select(c => c.Round + ":" + c.Frames)));
Check(_seatsA.Count > 0 && _seatsB.Count > 0 && _seatsB[0] == _seatsA[0], "rejoin_same_seat a=" + string.Join(",", _seatsA) + " b=" + string.Join(",", _seatsB));
Check(_hostMessages.Any(m => m.Contains("Slot wird vom Bot übernommen")), "bot_took_over");
Check(_hostMessages.Any(m => m.Contains("kehrt zurück auf")), "rejoin_announced");
Check(_errors.Count == 0, "no_script_errors " + string.Join(";", _errors));
}
static LuneSession Join(LuneLoopback hub, int peer, string name, List<int> seats, List<string> errors)
{
LuneSession guest = hub.Attach(peer, new LuneSession());
guest.SessionMessage += (text, isError) =>
{
if (isError)
errors.Add("guest " + peer + ": " + text);
};
guest.MatchResetup += () => seats.Add(guest.LocalSlot);
guest.JoinNetwork(name);
guest.OnClientConnected();
return guest;
}
static void PlanOnce(LuneSession session, ref int plannedRound, int slot, bool waitForPeer)
{
LuneMatch match = session.MatchState;
if (slot < 0 || plannedRound == match.RoundNumber || match.Players[slot].Ready)
return;
// The host waits for a seated guest in every planning phase (covers the rejoin case).
if (waitForPeer && session.SeatPeers.Count < 2)
return;
plannedRound = match.RoundNumber;
List<string> ids = match.BaseIds(match.Players[slot].Bund);
int gold = match.GoldOf(slot);
List<string> affordable = ids.Where(id => DataCatalog.GetUnit(id).Cost <= gold).ToList();
if (affordable.Count > 0)
session.Submit(Command.Buy(affordable[match.RoundNumber % affordable.Count]));
session.ReadyUp();
}
void NoteSums(LuneSession host, LuneSession guest, int fought)
{
if (fought < 1 || host.MatchState == null)
return;
_hostSums[fought] = LuneSession.Checksum(host.MatchState);
if (guest != null && guest.MatchState != null)
_guestSums[fought] = LuneSession.Checksum(guest.MatchState);
}
}
}