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();
}
}
}