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>
228 lines
11 KiB
C#
228 lines
11 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace Luneraria.Core.Tests
|
|
{
|
|
/// <summary>
|
|
/// Network protocol tests for the authoritative LuneSession (port of
|
|
/// saumwacht-godot/tests/luneraria/test_lune_net.gd, 43 checks). No rendering, no sockets:
|
|
/// the host entries take a peer id directly. Covers foreign seats, stale commands, double
|
|
/// purchase, bot takeover on disconnect and the rule that a disconnect is never a core loss.
|
|
/// </summary>
|
|
public sealed class NetSuite : CheckSuite
|
|
{
|
|
public override string Name { get { return "net"; } }
|
|
|
|
static LuneSession NewHost(int seed = 5)
|
|
{
|
|
var session = new LuneSession();
|
|
session.HostOffline(0, new MatchOptions
|
|
{
|
|
Seed = seed,
|
|
HumanSlot = 0,
|
|
Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" }
|
|
});
|
|
return session;
|
|
}
|
|
|
|
protected override void RunChecks()
|
|
{
|
|
SeatOwnership();
|
|
StaleCommand();
|
|
DoublePurchase();
|
|
ForeignUid();
|
|
DisconnectIsNotCoreLoss();
|
|
SixSeatsAndPhases();
|
|
HelloAndRejoin();
|
|
TakeoverAndBotOnce();
|
|
ReadyCommitsAndPrivacy();
|
|
}
|
|
|
|
void SeatOwnership()
|
|
{
|
|
LuneSession session = NewHost();
|
|
session.SeatPeer(1, 42, "Gast");
|
|
Check(session.SeatOfPeer(42) == 1, "peer is seated on slot 1");
|
|
// Peer 42 tries to act as slot 3.
|
|
SessionResult spoofed = session.HostCommand(42, 3, Command.Buy("w01"));
|
|
Check(!spoofed.Ok, "command from another seat is rejected");
|
|
// Unseated peer.
|
|
SessionResult orphan = session.HostCommand(99, 0, Command.Buy("w01"));
|
|
Check(!orphan.Ok, "command from an unseated peer is rejected");
|
|
// The owner may act during planning (bots are pre-ready, so do not ready up yet).
|
|
SessionResult own = session.HostCommand(1, 0, Command.Buy(session.MatchState.BaseIds("glutpakt")[0]));
|
|
Check(own.Ok, "the seat owner may buy");
|
|
session.Close();
|
|
}
|
|
|
|
void StaleCommand()
|
|
{
|
|
LuneSession session = NewHost();
|
|
session.HostReady(0);
|
|
for (int slot = 1; slot < 6; slot++)
|
|
session.HostReady(slot);
|
|
Check(session.MatchState.Phase == Phase.Combat, "all ready starts combat");
|
|
SessionResult late = session.HostCommand(1, 0, Command.Buy("w01"));
|
|
Check(!late.Ok && (late.Message.Contains("spät") || late.Message.Contains("vorbei")),
|
|
"stale planning command after the phase closed is rejected");
|
|
session.Close();
|
|
}
|
|
|
|
void DoublePurchase()
|
|
{
|
|
LuneSession session = NewHost();
|
|
List<string> ids = session.MatchState.BaseIds("glutpakt");
|
|
int cost = DataCatalog.GetUnit(ids[0]).Cost;
|
|
session.MatchState.Players[0].Draft.Gold = 1000;
|
|
SessionResult first = session.HostCommand(1, 0, Command.Buy(ids[0]));
|
|
int goldAfterFirst = session.MatchState.Players[0].Draft.Gold;
|
|
int rosterAfterFirst = session.MatchState.Players[0].Draft.Roster.Count;
|
|
Check(first.Ok && rosterAfterFirst == 1 && goldAfterFirst == 1000 - cost,
|
|
"one purchase spends the catalogue cost exactly once");
|
|
// A duplicated packet is another buy command: it may buy a second unit at a cost,
|
|
// but it must never hand out a free clone (ownership is the uid list plus gold).
|
|
session.HostCommand(1, 0, Command.Buy(ids[0]));
|
|
int rosterAfter = session.MatchState.Players[0].Draft.Roster.Count;
|
|
int goldAfter = session.MatchState.Players[0].Draft.Gold;
|
|
Check(rosterAfter == rosterAfterFirst + 1, "a repeated buy creates one more unit, not a free clone");
|
|
Check(goldAfter == goldAfterFirst - cost, "the repeated buy also pays exactly once");
|
|
var uids = new HashSet<string>();
|
|
foreach (RosterEntry entry in session.MatchState.Players[0].Draft.Roster)
|
|
uids.Add(entry.Uid);
|
|
Check(uids.Count == rosterAfter, "every owned unit has a unique id");
|
|
session.Close();
|
|
}
|
|
|
|
void ForeignUid()
|
|
{
|
|
LuneSession session = NewHost();
|
|
session.SeatPeer(1, 7, "Gast");
|
|
List<string> ids = session.MatchState.BaseIds("glutpakt");
|
|
session.MatchState.Players[0].Draft.Gold = 500;
|
|
session.HostCommand(1, 0, Command.Buy(ids[0]));
|
|
string uid = session.MatchState.Players[0].Draft.Roster[0].Uid;
|
|
// Guest tries to upgrade the host's unit.
|
|
SessionResult steal = session.HostCommand(7, 1, Command.Upgrade(uid));
|
|
Check(!steal.Ok, "a guest cannot upgrade a foreign unit id");
|
|
// Guest tries to act as the host seat.
|
|
SessionResult hijack = session.HostCommand(7, 0, Command.Upgrade(uid));
|
|
Check(!hijack.Ok, "a guest cannot act on the host seat");
|
|
// Owner may upgrade their own unit.
|
|
string upgradeId = DataCatalog.UpgradeOf(ids[0]) ?? "";
|
|
if (upgradeId.Length > 0)
|
|
{
|
|
session.MatchState.Players[0].Draft.Gold = 500;
|
|
SessionResult ok = session.HostCommand(1, 0, Command.Upgrade(uid));
|
|
Check(ok.Ok, "the owner may upgrade their own unit");
|
|
}
|
|
session.Close();
|
|
}
|
|
|
|
void DisconnectIsNotCoreLoss()
|
|
{
|
|
LuneSession session = NewHost();
|
|
session.SeatPeer(1, 50, "Gast");
|
|
session.SeatPeer(2, 51, "Gast2");
|
|
double core0 = session.MatchState.CoreHp(0);
|
|
double core1 = session.MatchState.CoreHp(1);
|
|
session.OnPeerDisconnected(50);
|
|
Check(session.MatchState.CoreHp(0) == core0 && session.MatchState.CoreHp(1) == core1,
|
|
"disconnect does not damage either Ancient");
|
|
Check(session.MatchState.Phase != Phase.Over, "disconnect does not end the match");
|
|
Check(session.MatchState.Winner == LuneMatch.Ongoing, "disconnect is not a core victory");
|
|
Check(!session.SeatPeers.ContainsKey(1), "the lost seat is no longer peer-held");
|
|
bool botPlanned = false;
|
|
foreach (LobbyEntry entry in session.Lobby)
|
|
{
|
|
if (entry.Slot == 1)
|
|
botPlanned = entry.Bot && entry.Ready;
|
|
}
|
|
Check(botPlanned, "the disconnected seat is taken over by a bot");
|
|
session.Close();
|
|
}
|
|
|
|
void SixSeatsAndPhases()
|
|
{
|
|
LuneSession session = NewHost();
|
|
Check(session.MatchState.Players.Count == 6, "six seats exist");
|
|
var teams = new Dictionary<int, int>();
|
|
foreach (Player player in session.MatchState.Players)
|
|
{
|
|
int count;
|
|
teams.TryGetValue(player.Team, out count);
|
|
teams[player.Team] = count + 1;
|
|
}
|
|
Check(teams.Count == 2 && teams[0] == 3 && teams[1] == 3, "two teams of three");
|
|
Check(session.MatchState.Map.Buildings.Count(d => d.Tier != "core") == 16, "sixteen towers");
|
|
Check(session.MatchState.Map.Buildings.Count(d => d.Tier == "core") == 2, "two Ancients");
|
|
session.HostReady(0);
|
|
for (int slot = 1; slot < 6; slot++)
|
|
session.HostReady(slot);
|
|
session.PlanBotSeats();
|
|
// Bots already planned via HostBeginCombat; ensure a full round works offline-style.
|
|
if (session.MatchState.Phase == Phase.Planning)
|
|
session.HostBeginCombat();
|
|
Check(session.MatchState.Phase == Phase.Combat, "combat starts when everyone is ready");
|
|
session.HostFinishCombat();
|
|
Check(session.MatchState.Phase == Phase.Planning || session.MatchState.Phase == Phase.Over,
|
|
"settlement returns to planning or ends");
|
|
session.Close();
|
|
}
|
|
|
|
void HelloAndRejoin()
|
|
{
|
|
LuneSession session = NewHost();
|
|
Check(session.HostHello(60, LuneNet.Protocol - 1, "Alt") == -1, "an older protocol is refused");
|
|
Check(session.SeatOfPeer(60) < 0, "a refused peer holds no seat");
|
|
int anna = session.HostHello(61, LuneNet.Protocol, "Anna");
|
|
Check(anna == 1, "the first guest takes the first free seat");
|
|
Check(session.HostHello(61, LuneNet.Protocol, "Anna") == anna, "a repeated hello keeps the one seat");
|
|
session.OnPeerDisconnected(61);
|
|
Check(session.MatchState.Players[anna].Ready && !session.MatchState.Players[anna].IsHuman,
|
|
"a departed seat is a ready bot");
|
|
int bert = session.HostHello(62, LuneNet.Protocol, "Bert");
|
|
Check(bert >= 0 && bert != anna, "a new guest does not take a seat reserved for rejoining");
|
|
int back = session.HostHello(63, LuneNet.Protocol, "Anna");
|
|
Check(back == anna, "a returning guest gets the same seat back by name");
|
|
Check(session.MatchState.Players[anna].IsHuman && !session.MatchState.Players[anna].Ready,
|
|
"the returned seat is human and must ready up");
|
|
session.Close();
|
|
}
|
|
|
|
void TakeoverAndBotOnce()
|
|
{
|
|
LuneSession session = NewHost();
|
|
session.PlanBotSeats();
|
|
Player bot = session.MatchState.Players[2];
|
|
int spentByBot = bot.Gold - bot.Draft.Gold;
|
|
Check(spentByBot > 0, "a bot seat plans and spends gold");
|
|
session.PlanBotSeats();
|
|
Check(bot.Gold - bot.Draft.Gold == spentByBot, "a bot seat plans only once per planning phase");
|
|
session.SeatPeer(2, 70, "Cora");
|
|
Check(bot.Draft.Gold == bot.Gold && bot.Draft.Roster.Count == 0,
|
|
"a takeover in planning drops the bot's uncommitted draft");
|
|
Check(!bot.Ready, "the new human must ready up");
|
|
session.Close();
|
|
}
|
|
|
|
void ReadyCommitsAndPrivacy()
|
|
{
|
|
LuneSession session = NewHost();
|
|
session.SeatPeer(1, 80, "Dora");
|
|
List<string> ids = session.MatchState.BaseIds("wurzelbund");
|
|
Check(session.HostCommand(80, 1, Command.Buy(ids[0])).Ok, "the guest buys during planning");
|
|
Check(session.HostCommand(80, 1, new Command { Type = "undo" }).Ok, "undo goes through the host");
|
|
Check(session.MatchState.Players[1].Draft.Roster.Count == 0, "undo removed the purchase");
|
|
session.HostCommand(80, 1, Command.Buy(ids[0]));
|
|
session.HostReady(1);
|
|
Check(!session.HostCommand(80, 1, Command.Buy(ids[0])).Ok, "after ready the plan is binding");
|
|
NetState state = session.StateFor(1);
|
|
List<NetPlayerRow> drafts = state.Players.Where(row => row.Draft != null).ToList();
|
|
Check(drafts.Count == 1 && drafts[0].Slot == 1, "a seat receives only its own draft");
|
|
Check(session.MatchState.Phase == Phase.Planning, "combat waits for the host seat");
|
|
session.HostReady(0);
|
|
Check(session.MatchState.Phase == Phase.Combat, "combat starts once every human is ready");
|
|
session.Close();
|
|
}
|
|
}
|
|
}
|