using System.Net; using System.Net.Sockets; using CapySR.Common; using CapySR.GameServer.Game; using CapySR.GameServer.Net; using CapySR.Protocol; using Microsoft.Extensions.Logging.Abstractions; using Xunit; using Xunit.Abstractions; namespace CapySR.Tests; // the whole pipeline through a real gateway: login, teams, overworld fights, a MOC floor public class FlowTests(ITestOutputHelper output) { private static GameWorld World => TestWorld.Current; // the default harness plays real stages so an assertion about a fight is about the data, // not about whatever freesr-data.json happens to hold private static ServerConfig TestConfig() => new() { GameServer = { Host = "127.0.0.1", Persist = false, BattleSource = "stage" }, }; private sealed class Harness : IAsyncDisposable { private readonly CancellationTokenSource _cancellation = new(TimeSpan.FromSeconds(60)); private readonly Task _server; public Harness(string battleSource = "stage") { var port = FreeUdpPort(); var config = TestConfig(); config.GameServer.Port = port; config.GameServer.BattleSource = battleSource; var handlers = HandlerRegistry.Build(NullLogger.Instance); var gateway = new KcpGateway(config, handlers, World, NullLogger.Instance); _server = gateway.RunAsync(_cancellation.Token); Client = new TestClient(new IPEndPoint(IPAddress.Loopback, port)); } public TestClient Client { get; } public async Task LoginAsync() { await Client.ConnectAsync(); await Client.RequestAsync(new PlayerGetTokenCsReq()); var login = await Client.RequestAsync(new PlayerLoginCsReq { LoginRandom = 1 }); Assert.Equal(0u, login.Retcode); Client.ClearInbox(); } public async ValueTask DisposeAsync() { Client.Dispose(); await _cancellation.CancelAsync(); await Task.WhenAny(_server, Task.Delay(2000)); } private static int FreeUdpPort() { using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); probe.Bind(new IPEndPoint(IPAddress.Loopback, 0)); return ((IPEndPoint)probe.LocalEndPoint!).Port; } } private static bool HasData => TestWorld.Available; [Fact] public async Task RosterAndTeamsLoad() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; var avatars = await client.RequestAsync(new GetAvatarDataCsReq { IsGetAll = true }); Assert.True(avatars.AvatarList.Count >= 4, $"only {avatars.AvatarList.Count} avatars"); Assert.All(avatars.AvatarPathDataInfoList, p => Assert.NotEmpty(p.AvatarPathSkillTree)); Assert.Contains(8001u, avatars.BasicTypeIdList); var all = await client.RequestAsync(new GetAllLineupDataCsReq()); Assert.Equal(LineupBook.SquadCount, all.LineupList.Count); Assert.Equal(0u, all.CurIndex); Assert.NotEmpty(all.LineupList[0].AvatarList); Assert.All(all.LineupList, l => Assert.Equal(ExtraLineupType.LineupNone, l.ExtraLineupType)); Assert.Equal([0u, 1u, 2u, 3u, 4u, 5u], all.LineupList.Select(l => l.Index)); var current = await client.RequestAsync(new GetCurLineupDataCsReq()); Assert.Equal(all.LineupList[0].AvatarList.Count, current.Lineup.AvatarList.Count); Assert.Equal(LineupBook.BaseMaxMp, current.Lineup.MaxMp); var picker = await client.RequestAsync(new GetLineupAvatarDataCsReq()); Assert.True(picker.AvatarDataList.Count >= avatars.AvatarList.Count); Assert.All(picker.AvatarDataList, a => Assert.Equal(Player.FullHp, a.Hp)); } [Fact] public async Task TeamEditsRoundTripAndRefreshTheScene() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; var scene = await client.RequestAsync(new GetCurSceneInfoCsReq()); var actorsBefore = scene.Scene.EntityGroupList.SelectMany(g => g.EntityList).Where(e => e.Actor is not null).ToList(); Assert.NotEmpty(actorsBefore); client.ClearInbox(); var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList(); var a = owned[0]; var b = owned[1]; var c = owned[2]; var replace = new ReplaceLineupCsReq { Index = 0, LeaderSlot = 1 }; replace.LineupSlotList.Add(new LineupSlotData { Slot = 0, Id = a, AvatarType = AvatarType.AvatarFormalType }); replace.LineupSlotList.Add(new LineupSlotData { Slot = 1, Id = b, AvatarType = AvatarType.AvatarFormalType }); var replaced = await client.RequestAsync(replace); Assert.Equal(0u, replaced.Retcode); var sync = await client.ExpectAsync(); Assert.Equal([a, b], sync.Lineup.AvatarList.Select(x => x.Id)); Assert.Equal(1u, sync.Lineup.LeaderSlot); // the party model changed, so the scene had to swap actors in place await client.PumpAsync(TimeSpan.FromMilliseconds(300)); var refreshes = client.Take(); Assert.NotEmpty(refreshes); Assert.Contains(refreshes.SelectMany(r => r.GroupRefreshList), g => g.GroupId == 0 && g.RefreshEntity.Count > 0); var join = await client.RequestAsync(new JoinLineupCsReq { Index = 0, Slot = 2, OPCJCDKMJEI = c }); Assert.Equal(0u, join.Retcode); sync = await client.ExpectAsync(); Assert.Equal([a, b, c], sync.Lineup.AvatarList.Select(x => x.Id)); Assert.Equal([0u, 1u, 2u], sync.Lineup.AvatarList.Select(x => x.Slot)); var swap = await client.RequestAsync(new SwapLineupCsReq { Index = 0, MPIEGFCNBEN = 0, ENBIBCPPLDC = 2 }); Assert.Equal(0u, swap.Retcode); sync = await client.ExpectAsync(); Assert.Equal([c, b, a], sync.Lineup.AvatarList.Select(x => x.Id)); // the leader is a character, not a chair Assert.Equal(1u, sync.Lineup.LeaderSlot); var leader = await client.RequestAsync(new ChangeLineupLeaderCsReq { Slot = 2 }); Assert.Equal(0u, leader.Retcode); Assert.Equal(2u, leader.Slot); sync = await client.ExpectAsync(); Assert.Equal(2u, sync.Lineup.LeaderSlot); var quit = await client.RequestAsync(new QuitLineupCsReq { Index = 0, BaseAvatarId = b }); Assert.Equal(0u, quit.Retcode); sync = await client.ExpectAsync(); Assert.Equal([c, a], sync.Lineup.AvatarList.Select(x => x.Id)); Assert.Equal([0u, 2u], sync.Lineup.AvatarList.Select(x => x.Slot)); // an empty squad cannot become the walking team var empty = await client.RequestAsync(new SwitchLineupIndexCsReq { Index = 3 }); Assert.Equal((uint)Retcode.RetLineupIsEmpty, empty.Retcode); var named = await client.RequestAsync(new SetLineupNameCsReq { Index = 3, Name = "Bench" }); Assert.Equal("Bench", named.Name); var fill = new ReplaceLineupCsReq { Index = 3 }; fill.LineupSlotList.Add(new LineupSlotData { Slot = 0, Id = b, AvatarType = AvatarType.AvatarFormalType }); await client.RequestAsync(fill); var switched = await client.RequestAsync(new SwitchLineupIndexCsReq { Index = 3 }); Assert.Equal(0u, switched.Retcode); var current = await client.RequestAsync(new GetCurLineupDataCsReq()); Assert.Equal("Bench", current.Lineup.Name); Assert.Equal([b], current.Lineup.AvatarList.Select(x => x.Id)); Assert.Equal(3u, current.Lineup.Index); } [Fact] public async Task HittingAMonsterStartsAndSettlesAFight() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; var scene = await client.RequestAsync(new GetCurSceneInfoCsReq()); var entities = scene.Scene.EntityGroupList.SelectMany(g => g.EntityList).ToList(); var actor = entities.First(e => e.Actor is not null); var monster = entities.First(e => e.NpcMonster is not null && e.NpcMonster.EventId != 0); client.ClearInbox(); var cast = new SceneCastSkillCsReq { CastEntityId = actor.EntityId, AttackedByEntityId = actor.EntityId, SkillIndex = 0, }; cast.HitTargetEntityIdList.Add(monster.EntityId); cast.AssistMonsterEntityIdList.Add(monster.EntityId); var response = await client.RequestAsync(cast); Assert.Equal(0u, response.Retcode); Assert.NotNull(response.BattleInfo); var hit = Assert.Single(response.MonsterBattleInfo); Assert.Equal(monster.EntityId, hit.TargetMonsterEntityId); Assert.Equal(MonsterBattleType.TriggerBattle, hit.MonsterBattleType); var battle = response.BattleInfo; output.WriteLine($"stage {battle.StageId}: {battle.BattleAvatarList.Count} avatars, {battle.MonsterWaveList.Count} waves, {battle.BuffList.Count} buffs"); Assert.NotEqual(0u, battle.StageId); Assert.NotEmpty(battle.BattleAvatarList); Assert.NotEmpty(battle.MonsterWaveList); // the overworld has no cycle limit; only challenges count rounds Assert.Equal(0u, battle.RoundsLimit); Assert.Equal((uint)battle.MonsterWaveList.Count, battle.MonsterWaveLength); Assert.Equal(Enumerable.Range(0, battle.BattleAvatarList.Count).Select(i => (uint)i), battle.BattleAvatarList.Select(a => a.Index)); // the attacker opened with a normal attack, so the entry buff says so var opener = battle.BuffList.FirstOrDefault(b => b.Id is >= 1000111 and <= 1000117); Assert.NotNull(opener); Assert.Equal(1f, opener.DynamicValues["SkillIndex"]); // the client can ask again mid-fight and gets the same seed back var current = await client.RequestAsync(new GetCurBattleInfoCsReq()); Assert.Equal(battle.LogicRandomSeed, current.BattleInfo.LogicRandomSeed); var result = new PVEBattleResultCsReq { BattleId = battle.BattleId, StageId = battle.StageId, EndStatus = BattleEndStatus.BattleEndWin, Stt = new BattleStatistics { RoundCnt = 3 }, }; foreach (var avatar in battle.BattleAvatarList) { result.Stt.BattleAvatarList.Add(new AvatarBattleInfo { Id = avatar.Id, AvatarType = AvatarType.AvatarFormalType, AvatarStatus = new AvatarProperty { MaxHp = 1000, LeftHp = 500, MaxSp = 120, LeftSp = 60 }, }); } var settled = await client.RequestAsync(result); Assert.Equal(0u, settled.Retcode); Assert.Equal(BattleEndStatus.BattleEndWin, settled.EndStatus); Assert.Equal(battle.BattleId, settled.BattleId); // the monster is gone from the map and the party remembers its wounds var refresh = await client.ExpectAsync(); Assert.Contains(refresh.GroupRefreshList.SelectMany(g => g.RefreshEntity), e => e.DeleteEntity == monster.EntityId); var sync = await client.ExpectAsync(); Assert.All(sync.Lineup.AvatarList, a => Assert.Equal(5000u, a.Hp)); Assert.All(sync.Lineup.AvatarList, a => Assert.Equal(60u, a.SpBar.CurSp)); var after = await client.RequestAsync(new GetCurSceneInfoCsReq()); Assert.DoesNotContain(after.Scene.EntityGroupList.SelectMany(g => g.EntityList), e => e.EntityId == monster.EntityId); // nothing is left to fight, so no battle is offered var idle = await client.RequestAsync(new GetCurBattleInfoCsReq()); Assert.Equal(0u, idle.BattleInfo.StageId); Assert.Equal(BattleEndStatus.BattleEndWin, idle.LastEndStatus); } // a calyx is how an older MOC/PF/AS stage gets replayed: it runs whatever srtools uploaded [Fact] public async Task CalyxPlaysTheConfiguredBattle() { if (!HasData) { return; } var configured = World.Player.BattleConfig; if (configured.StageId == 0) { output.WriteLine("no srtools battle_config; skipping"); return; } var request = new StartCocoonStageCsReq { CocoonId = 1001, Wave = 1, WorldLevel = 6, PropEntityId = 7 }; await using (var auto = new Harness("auto")) { await auto.LoginAsync(); var rsp = await auto.Client.RequestAsync(request); Assert.Equal(0u, rsp.Retcode); Assert.Equal(7u, rsp.PropEntityId); Assert.Equal(configured.StageId, rsp.BattleInfo.StageId); } // "stage" opts out and plays the calyx's own encounter await using var stages = new Harness("stage"); await stages.LoginAsync(); var own = await stages.Client.RequestAsync(request); Assert.Equal(0u, own.Retcode); Assert.NotEqual(configured.StageId, own.BattleInfo.StageId); Assert.NotNull(World.Data.GetStage(own.BattleInfo.StageId)); } [Fact] public async Task TechniquesSpendPointsAndTeleportsRestoreThem() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; var cost = await client.RequestAsync(new SceneCastSkillCostMpCsReq { CastEntityId = 101001 }); Assert.Equal(0u, cost.Retcode); var update = await client.ExpectAsync(); Assert.Equal(LineupBook.BaseMaxMp - 1, update.Mp); var entry = new GameServerConfig().StartEntryId; var enter = await client.RequestAsync(new EnterSceneCsReq { EntryId = entry, TeleportId = new GameServerConfig().StartTeleportId }); Assert.Equal(0u, enter.Retcode); var scene = await client.ExpectAsync(); Assert.Equal(entry, scene.Scene.EntryId); Assert.Equal(LineupBook.BaseMaxMp, scene.Lineup.Mp); Assert.NotEmpty(scene.Scene.EntityGroupList); } // the party bar only redraws from the notify, and the client stops listening once the // request it sent is answered, so the notify has to be on the wire first [Fact] public async Task TheTeamIsPushedBeforeTheEditIsAnswered() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; await client.RequestAsync(new GetCurSceneInfoCsReq()); var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList(); client.ClearInbox(); var replace = new ReplaceLineupCsReq { Index = 0, LeaderSlot = 0 }; replace.LineupSlotList.Add(new LineupSlotData { Slot = 0, Id = owned[2], AvatarType = AvatarType.AvatarFormalType }); replace.LineupSlotList.Add(new LineupSlotData { Slot = 1, Id = owned[3], AvatarType = AvatarType.AvatarFormalType }); await client.RequestAsync(replace); var order = client.ArrivalOrder.ToList(); var sync = order.IndexOf(nameof(SyncLineupNotify)); var scene = order.IndexOf(nameof(SceneGroupRefreshScNotify)); var answer = order.IndexOf(nameof(ReplaceLineupScRsp)); Assert.True(sync >= 0, $"no SyncLineupNotify, got [{string.Join(", ", order)}]"); Assert.True(scene >= 0, $"no SceneGroupRefreshScNotify, got [{string.Join(", ", order)}]"); Assert.True(answer >= 0); Assert.True(scene < answer, $"scene refresh must precede the answer, got [{string.Join(", ", order)}]"); Assert.True(sync < answer, $"lineup sync must precede the answer, got [{string.Join(", ", order)}]"); } [Fact] public async Task EveryChallengeFloorIsReachable() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var list = await harness.Client.RequestAsync(new GetChallengeCsReq()); Assert.Equal(World.Data.Challenges.Count, list.ChallengeList.Count); Assert.Equal(World.Data.ChallengeGroups.Count, list.ChallengeGroupList.Count); // a floor with no stars locks the one after it, so none may be left at zero Assert.All(list.ChallengeList, c => Assert.True(c.Star > 0, $"floor {c.ChallengeId} is locked")); // pure fiction and apocalyptic shadow open on score, taken from their target rows foreach (var challenge in World.Data.Challenges.Values.Where(c => c.Kind != Data.Excel.ChallengeKind.MemoryOfChaos)) { var reported = list.ChallengeList.First(c => c.ChallengeId == challenge.ID); var target = challenge.ChallengeTargetID .Select(id => World.Data.ChallengeTargets.GetValueOrDefault(id)) .Where(t => t?.ChallengeTargetType == "TOTAL_SCORE") .Select(t => t!.ChallengeTargetParam1) .DefaultIfEmpty(0u) .Max(); Assert.True(reported.ScoreId >= target, $"floor {challenge.ID} scored {reported.ScoreId}, needs {target}"); } } [Fact] public async Task ChatCommandsAnswerThroughTheBotFriend() { if (!HasData) { return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; var friends = await client.RequestAsync(new GetFriendListInfoCsReq()); var bot = Assert.Single(friends.FriendList); Assert.Equal(GameServer.Handlers.ChatHandlers.BotUid, bot.PlayerInfo.Uid); var help = await Say(client, "/help"); Assert.Contains("/tp", help); var unknown = await Say(client, "/nope"); Assert.Contains("Unknown command", unknown); await Say(client, "/wl 3"); var basic = await client.ExpectAsync(); Assert.Equal(3u, basic.BasicInfo.WorldLevel); await Say(client, "/mp 2"); var sync = await client.ExpectAsync(); Assert.Equal(2u, sync.Lineup.Mp); // the next fight plays the requested stage, whatever was hit var reply = await Say(client, "/stage 1022010"); Assert.Contains("1022010", reply); var scene = await client.RequestAsync(new GetCurSceneInfoCsReq()); var entities = scene.Scene.EntityGroupList.SelectMany(g => g.EntityList).ToList(); var actor = entities.First(e => e.Actor is not null); var monster = entities.First(e => e.NpcMonster is not null); var cast = new SceneCastSkillCsReq { CastEntityId = actor.EntityId, AttackedByEntityId = actor.EntityId }; cast.HitTargetEntityIdList.Add(monster.EntityId); var battle = await client.RequestAsync(cast); Assert.Equal(1022010u, battle.BattleInfo.StageId); Assert.Equal(3u, battle.BattleInfo.WorldLevel); } private static async Task Say(TestClient client, string text) { client.ClearInbox(); var message = new SendMsgCsReq { ChatType = ChatType.Private, MessageDatas = new MessageChatData { MessageType = MsgType.CustomText, ChatData = new ChatData { MessageText = text }, }, }; message.TargetList.Add(GameServer.Handlers.ChatHandlers.BotUid); var sent = await client.RequestAsync(message); Assert.Equal(0u, sent.Retcode); var reply = await client.ExpectAsync(); Assert.Equal(GameServer.Handlers.ChatHandlers.BotUid, reply.SourceUid); return reply.RecvMessageData.MessageDatas[0].ChatData.MessageText; } [Fact] public async Task MemoryOfChaosRunsBothNodesAndSettles() { if (!HasData) { return; } if (!HasData) { return; } var floor = World.Data.Challenges.Values .Where(c => c.Kind == Data.Excel.ChallengeKind.MemoryOfChaos && c.NodeCount == 2) .OrderBy(c => c.ID) .FirstOrDefault(c => new ChallengeState().StartChallenge(World.Data, c, 0, 0, [1001]) && new ChallengeState().StartChallenge(World.Data, c, 1, 0, [1001])); if (floor is null) { output.WriteLine("no two-node MOC floor resolves; skipping"); return; } await using var harness = new Harness(); await harness.LoginAsync(); var client = harness.Client; var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList(); var first = owned.Take(2).ToList(); var second = owned.Skip(2).Take(2).ToList(); var start = new StartChallengeCsReq { ChallengeId = floor.ID }; start.AvatarLineupFirst.AddRange(first.Select(id => new AvatarIdentifier { Id = id, AvatarType = AvatarType.AvatarFormalType })); start.AvatarLineupSecond.AddRange(second.Select(id => new AvatarIdentifier { Id = id, AvatarType = AvatarType.AvatarFormalType })); var started = await client.RequestAsync(start); Assert.Equal(0u, started.Retcode); Assert.Equal(floor.ID, started.CurChallenge.ChallengeId); Assert.Equal(ChallengeStatus.ChallengeDoing, started.CurChallenge.Status); Assert.Equal(ExtraLineupType.LineupChallenge, started.CurChallenge.ExtraLineupType); Assert.Equal(2, started.LineupList.Count); Assert.Equal(first, started.LineupList[0].AvatarList.Select(a => a.Id)); Assert.Equal(second, started.LineupList[1].AvatarList.Select(a => a.Id)); Assert.True(started.LineupList[0].IsVirtual); var actors = started.Scene.EntityGroupList.SelectMany(g => g.EntityList).Where(e => e.Actor is not null).ToList(); Assert.Equal(first.Count, actors.Count); await FightNodeAsync(client, started.Scene, first.Count, expectSettle: false); // the second half arrives as a fresh scene with the second team var lineupNotify = await client.ExpectAsync(); Assert.Equal(ExtraLineupType.LineupChallenge2, lineupNotify.ExtraLineupType); var node2 = await client.ExpectAsync(); Assert.Equal(second, node2.Lineup.AvatarList.Select(a => a.Id)); Assert.Equal(ExtraLineupType.LineupChallenge2, node2.Lineup.ExtraLineupType); var cur = await client.RequestAsync(new GetCurChallengeCsReq()); Assert.Equal(ExtraLineupType.LineupChallenge2, cur.CurChallenge.ExtraLineupType); await FightNodeAsync(client, node2.Scene, second.Count, expectSettle: true); var settle = await client.ExpectAsync(); Assert.Equal(floor.ID, settle.ChallengeId); Assert.True(settle.IsWin); Assert.True(settle.Star > 0, "a full clear should award stars"); // the record shows up on the floor list afterwards var list = await client.RequestAsync(new GetChallengeCsReq()); var record = list.ChallengeList.First(c => c.ChallengeId == floor.ID); Assert.Equal(settle.Star, record.Star); var left = await client.RequestAsync(new LeaveChallengeCsReq()); Assert.Equal(0u, left.Retcode); var world = await client.ExpectAsync(); Assert.Equal(ExtraLineupType.LineupNone, world.Lineup.ExtraLineupType); Assert.Equal(new GameServerConfig().StartEntryId, world.Scene.EntryId); } private static async Task FightNodeAsync(TestClient client, SceneInfo scene, int partySize, bool expectSettle) { var entities = scene.EntityGroupList.SelectMany(g => g.EntityList).ToList(); var actor = entities.First(e => e.Actor is not null); var monster = entities.First(e => e.NpcMonster is not null); client.ClearInbox(); var cast = new SceneCastSkillCsReq { CastEntityId = actor.EntityId, AttackedByEntityId = actor.EntityId, SkillIndex = 1 }; cast.HitTargetEntityIdList.Add(monster.EntityId); var response = await client.RequestAsync(cast); var battle = response.BattleInfo; Assert.NotNull(battle); Assert.Equal(partySize, battle.BattleAvatarList.Count); Assert.NotEmpty(battle.MonsterWaveList); Assert.True(battle.RoundsLimit is > 0 and <= 30); var result = new PVEBattleResultCsReq { BattleId = battle.BattleId, StageId = battle.StageId, EndStatus = BattleEndStatus.BattleEndWin, Stt = new BattleStatistics { RoundCnt = 2 }, }; var settled = await client.RequestAsync(result); Assert.Equal(BattleEndStatus.BattleEndWin, settled.EndStatus); await client.PumpAsync(TimeSpan.FromMilliseconds(300)); } }