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>
190 lines
9.4 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|