using System.Collections.Generic; using System.Linq; namespace Luneraria.Core.Tests { /// /// 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. /// 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 { "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 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(); 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 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(); 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 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 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(); } } }