feat(init): server has been rewritten

This commit is contained in:
capyb2222 committed 2026-09-07 22:19:20 -07:00
1 parent 3647ed19c4
commit b87874f2fe
84 files changed
+1437031 -1

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
using CapySR.Common;
using CapySR.Data;
using CapySR.Data.SrTools;
using CapySR.GameServer.Battle;
using Xunit;
using Xunit.Abstractions;
namespace CapySR.Tests;
public class BattleTests(ITestOutputHelper output)
{
private const uint AllWaves = 0xFFFFFFFF;
private static GameData? Load()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
return sources.Count == 0 ? null : GameData.Load(sources);
}
private static SrToolsData PlayerWith(params uint[] avatarIds)
{
var data = new SrToolsData();
foreach (var id in avatarIds)
{
data.Avatars[id] = new SrAvatar { AvatarId = id, Level = 80, Promotion = 6 };
}
return data;
}
private static BattleRequest Request(uint stageId, params uint[] lineup) => new()
{
StageId = stageId,
Lineup = lineup,
CycleCount = 30,
};
[Fact]
public void BuildsBattleFromStageData()
{
var data = Load();
if (data is null)
{
return;
}
var builder = new BattleBuilder(data);
var battle = builder.Build(Request(1022010, 1001, 1003), PlayerWith(1001, 1003));
Assert.Equal(1022010u, battle.StageId);
Assert.Equal(2, battle.BattleAvatarList.Count);
Assert.Equal(30u, battle.RoundsLimit);
// stage 1022010 is one wave of four monsters
var wave = Assert.Single(battle.MonsterWaveList);
Assert.Equal(1u, wave.BattleWaveId);
Assert.Equal([8001010u, 8001020u, 8001010u, 8001020u], wave.MonsterList.Select(m => m.MonsterId));
Assert.Equal(16u, wave.MonsterParam.Level);
Assert.Equal((uint)battle.MonsterWaveList.Count, battle.MonsterWaveLength);
Assert.NotEqual(0u, battle.LogicRandomSeed);
}
[Fact]
public void EveryAvatarProducesAUsableBattleAvatar()
{
var data = Load();
if (data is null)
{
return;
}
var builder = new BattleBuilder(data);
var noTree = new List<uint>();
var noBuff = new List<uint>();
// playable ranges only - 6xxx/7xxx are npc and trial models with no trace tree
var playable = data.Avatars.Keys
.Where(id => id is (> 1000 and < 2000) or (>= 8001 and <= 8010))
.OrderBy(id => id)
.ToList();
foreach (var avatarId in playable)
{
var battle = builder.Build(Request(1022010, avatarId), PlayerWith(avatarId));
var avatar = Assert.Single(battle.BattleAvatarList);
if (avatar.SkilltreeList.Count == 0)
{
noTree.Add(avatarId);
}
if (!battle.BuffList.Any(b => b.OwnerIndex == 0 && b.Id != 0))
{
noBuff.Add(avatarId);
}
}
output.WriteLine($"checked {playable.Count} playable avatars");
Assert.True(playable.Count >= 90, $"only found {playable.Count} playable avatars");
Assert.True(noTree.Count == 0, $"no skill tree: {string.Join(", ", noTree)}");
Assert.True(noBuff.Count == 0, $"no technique buff: {string.Join(", ", noBuff)}");
}
[Fact]
public void NewBetaCharacterBuildsCompletely()
{
var data = Load();
if (data is null || !data.Avatars.ContainsKey(1503))
{
return;
}
var builder = new BattleBuilder(data);
var battle = builder.Build(Request(1022010, 1503), PlayerWith(1503));
var pearl = Assert.Single(battle.BattleAvatarList);
Assert.Equal(1503u, pearl.Id);
Assert.True(pearl.SkilltreeList.Count >= 10, $"only {pearl.SkilltreeList.Count} trace points");
// 1503 is absent from AvatarDefaultMazeBuff, so it must come from the fallback table
Assert.Contains(battle.BuffList, b => b.Id == 150301);
Assert.Contains(battle.BuffList, b => b.Id == 1000121);
Assert.DoesNotContain(battle.BuffList, b => b.Id == 150301 + 0 && b.WaveFlag != AllWaves);
}
// guards against regressing away from what the 4.6 reference server sends
[Theory]
[InlineData(1001, 100101u)]
[InlineData(1208, 120802u)]
[InlineData(1304, 130403u)]
[InlineData(1308, 130803u)]
[InlineData(1310, 131001u)]
[InlineData(1412, 141201u)]
[InlineData(1510, 151002u)]
public void TechniqueBuffsMatchTheReference(uint avatarId, uint expectedBuffId)
{
var data = Load();
if (data is null)
{
return;
}
var buffs = TechniqueBuffs.For(data, avatarId).Select(b => b.Id).ToList();
Assert.Contains(expectedBuffId, buffs);
}
[Theory]
[InlineData(1208, 120801u)]
[InlineData(1310, 1000112u)]
[InlineData(1412, 1000121u)]
[InlineData(1224, 122401u)]
public void ExtraTechniqueBuffsAreKept(uint avatarId, uint extraBuffId)
{
var data = Load();
if (data is null)
{
return;
}
Assert.Contains(extraBuffId, TechniqueBuffs.For(data, avatarId).Select(b => b.Id));
}
[Fact]
public void FemaleTrailblazerPathsReuseMaleBuffs()
{
var data = Load();
if (data is null)
{
return;
}
foreach (var (female, male) in new[] { (8004u, 8003u), (8006u, 8005u), (8008u, 8007u), (8010u, 8009u) })
{
var f = TechniqueBuffs.For(data, female).Select(b => b.Id).ToList();
var m = TechniqueBuffs.For(data, male).Select(b => b.Id).ToList();
Assert.Equal(m, f);
}
}
[Theory]
[InlineData("Physical", 1000111u)]
[InlineData("Fire", 1000112u)]
[InlineData("Ice", 1000113u)]
[InlineData("Thunder", 1000114u)]
[InlineData("Wind", 1000115u)]
[InlineData("Quantum", 1000116u)]
[InlineData("Imaginary", 1000117u)]
public void AttackerBuffFollowsElement(string element, uint expected)
{
Assert.Equal(expected, TechniqueBuffs.AttackerBuff(element));
}
[Fact]
public void LeaderElementDrivesTheAttackerBuff()
{
var data = Load();
if (data is null)
{
return;
}
var builder = new BattleBuilder(data);
// 1001 March 7th is Ice
var battle = builder.Build(Request(1022010, 1001), PlayerWith(1001));
Assert.Contains(battle.BuffList, b => b.Id == 1000113 && b.DynamicValues["SkillIndex"] == 1f);
}
[Fact]
public void GlobalBuffsOnlyApplyWhenTheAvatarIsOwned()
{
var data = Load();
if (data is null || data.GlobalMazeBuffs.Count == 0)
{
return;
}
var builder = new BattleBuilder(data);
var (globalAvatar, globalBuff) = data.GlobalMazeBuffs[0];
var without = builder.Build(Request(1022010, 1001), PlayerWith(1001));
Assert.DoesNotContain(without.BuffList, b => b.Id == globalBuff);
var with = builder.Build(Request(1022010, 1001), PlayerWith(1001, globalAvatar));
Assert.Contains(with.BuffList, b => b.Id == globalBuff);
}
[Fact]
public void GlobalBuffsAreTheOnesTheReferenceHardcodes()
{
var data = Load();
if (data is null)
{
return;
}
var ids = data.GlobalMazeBuffs.Select(g => g.BuffId).OrderBy(i => i).ToList();
Assert.Equal([140703u, 150602u], ids);
}
[Fact]
public void PureFictionGetsScoreTargets()
{
var data = Load();
if (data is null)
{
return;
}
var builder = new BattleBuilder(data);
var request = Request(30301001, 1001);
request.Kind = BattleKind.PureFiction;
var battle = builder.Build(request, PlayerWith(1001));
Assert.Equal(5, battle.BattleTargetInfo.Count);
Assert.Contains(battle.BattleTargetInfo[1].BattleTargetList_, t => t.Id is 10002 or 10003);
Assert.Equal([2001u, 2002u], battle.BattleTargetInfo[5].BattleTargetList_.Select(t => t.Id));
}
[Fact]
public void ApocalypticShadowGetsItsGauge()
{
var data = Load();
if (data is null)
{
return;
}
var builder = new BattleBuilder(data);
var request = Request(1022010, 1001);
request.Kind = BattleKind.ApocalypticShadow;
var battle = builder.Build(request, PlayerWith(1001));
Assert.Equal(90005u, Assert.Single(battle.BattleTargetInfo[1].BattleTargetList_).Id);
}
[Fact]
public void InvadedStagesCarryTheInvasionBuff()
{
var data = Load();
if (data is null || data.StageInvasions.Count == 0)
{
return;
}
var invasion = data.StageInvasions.Values.First();
var battle = new BattleBuilder(data).Build(Request(invasion.StageID, 1001), PlayerWith(1001));
Assert.Contains(battle.BuffList, b => b.Id == 3034000 + invasion.InvasionID);
var plain = new BattleBuilder(data).Build(Request(1022010, 1001), PlayerWith(1001));
Assert.DoesNotContain(plain.BuffList, b => b.Id is >= 3034000 and < 3035000);
}
[Fact]
public void EnhancedAvatarsCarryTheirEnhancedId()
{
var data = Load();
if (data is null)
{
return;
}
var builder = new BattleBuilder(data);
// 1310 is one of the ten in AvatarConfigEnhanced; the kit is a toggle, off by default
var untoggled = builder.Build(Request(1022010, 1310), PlayerWith(1310));
Assert.Equal(0u, Assert.Single(untoggled.BattleAvatarList).EnhancedId);
var toggled = Request(1022010, 1310);
toggled.IsEnhanced = _ => true;
Assert.NotEqual(0u, Assert.Single(builder.Build(toggled, PlayerWith(1310)).BattleAvatarList).EnhancedId);
// srtools can switch it on too
var srtools = PlayerWith(1310);
srtools.Avatars[1310].EnhancedId = 1;
Assert.Equal(1u, Assert.Single(builder.Build(Request(1022010, 1310), srtools).BattleAvatarList).EnhancedId);
// an avatar without an enhanced kit never gets one, toggle or not
var plain = Request(1022010, 1001);
plain.IsEnhanced = _ => true;
Assert.Equal(0u, Assert.Single(builder.Build(plain, PlayerWith(1001)).BattleAvatarList).EnhancedId);
}
[Fact]
public void SrToolsGearReachesTheBattle()
{
var data = Load();
if (data is null)
{
return;
}
var player = PlayerWith(1001);
player.Lightcones.Add(new SrLightcone { ItemId = 23001, EquipAvatar = 1001, Level = 80, Rank = 5, Promotion = 6, InternalUid = 1 });
player.Relics.Add(new SrRelic
{
RelicId = 61011,
EquipAvatar = 1001,
Level = 15,
MainAffixId = 1,
InternalUid = 7,
SubAffixes = [new SrSubAffix { SubAffixId = 2, Count = 5, Step = 3 }],
});
var battle = new BattleBuilder(data).Build(Request(1022010, 1001), player);
var avatar = Assert.Single(battle.BattleAvatarList);
var lightcone = Assert.Single(avatar.EquipmentList);
Assert.Equal(23001u, lightcone.Id);
Assert.Equal(5u, lightcone.Rank);
var relic = Assert.Single(avatar.RelicList);
Assert.Equal(61011u, relic.Id);
var affix = Assert.Single(relic.SubAffixList);
Assert.Equal(5u, affix.Cnt);
Assert.Equal(3u, affix.Step);
}
[Fact]
public void CustomMonsterWavesOverrideTheStage()
{
var data = Load();
if (data is null)
{
return;
}
var request = Request(1022010, 1001);
request.MonsterWaves =
[
[new SrMonster { MonsterId = 3014022, Level = 95 }],
[new SrMonster { MonsterId = 3014023, Level = 95 }, new SrMonster { MonsterId = 3014024, Level = 95 }],
];
var battle = new BattleBuilder(data).Build(request, PlayerWith(1001));
Assert.Equal(2, battle.MonsterWaveList.Count);
Assert.Equal(2u, battle.MonsterWaveLength);
Assert.Equal([1u, 2u], battle.MonsterWaveList.Select(w => w.BattleWaveId));
Assert.Equal(95u, battle.MonsterWaveList[0].MonsterParam.Level);
}
[Fact]
public void BuffsTargetEveryWave()
{
var data = Load();
if (data is null)
{
return;
}
var battle = new BattleBuilder(data).Build(Request(1022010, 1001, 1003), PlayerWith(1001, 1003));
Assert.NotEmpty(battle.BuffList);
Assert.All(battle.BuffList, b => Assert.Equal(AllWaves, b.WaveFlag));
Assert.All(battle.BuffList, b => Assert.NotEqual(0u, b.Id));
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CapySR.Common\CapySR.Common.csproj" />
<ProjectReference Include="..\..\src\CapySR.Data\CapySR.Data.csproj" />
<ProjectReference Include="..\..\src\CapySR.GameServer\CapySR.GameServer.csproj" />
<ProjectReference Include="..\..\src\CapySR.Kcp\CapySR.Kcp.csproj" />
<ProjectReference Include="..\..\src\CapySR.Protocol\CapySR.Protocol.csproj" />
</ItemGroup>
</Project>
+324
View File
@@ -0,0 +1,324 @@
using CapySR.Common;
using CapySR.Data;
using CapySR.Data.Excel;
using CapySR.GameServer.Game;
using CapySR.GameServer.Scene;
using Xunit;
using Xunit.Abstractions;
namespace CapySR.Tests;
public class ChallengeTests(ITestOutputHelper output)
{
private static GameData? Load()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
return sources.Count == 0 ? null : GameData.Load(sources);
}
private static readonly uint[] Lineup = [1001, 1003, 1004, 1005];
[Fact]
public void EveryChallengeFloorResolvesAScene()
{
var data = Load();
if (data is null)
{
return;
}
var failed = new List<uint>();
var noStage = new List<uint>();
foreach (var challenge in data.Challenges.Values.OrderBy(c => c.ID))
{
var state = new ChallengeState();
if (!state.StartChallenge(data, challenge, 0, 0, Lineup))
{
failed.Add(challenge.ID);
continue;
}
if (data.GetStage(state.StageId) is null)
{
noStage.Add(challenge.ID);
}
}
output.WriteLine($"{data.Challenges.Count} floors, {failed.Count} unresolvable, {noStage.Count} without a stage");
Assert.True(failed.Count == 0, $"no scene for: {string.Join(", ", failed.Take(12))}");
Assert.True(noStage.Count == 0, $"no stage for: {string.Join(", ", noStage.Take(12))}");
}
[Fact]
public void SecondNodeResolvesWhereDefined()
{
var data = Load();
if (data is null)
{
return;
}
var withSecond = data.Challenges.Values.Where(c => c.HasSecondNode).ToList();
output.WriteLine($"{withSecond.Count}/{data.Challenges.Count} floors have a second node");
Assert.NotEmpty(withSecond);
foreach (var challenge in withSecond.Take(50))
{
var state = new ChallengeState();
Assert.True(state.StartChallenge(data, challenge, 1, 0, Lineup), $"floor {challenge.ID} node 2");
Assert.Equal(1, state.Node);
Assert.NotEqual(0u, state.StageId);
}
}
[Fact]
public void ChallengeCarriesMazeBuffAndPlayerPick()
{
var data = Load();
if (data is null)
{
return;
}
var challenge = data.Challenges.Values.First(c => c.MazeBuffID != 0);
var state = new ChallengeState();
Assert.True(state.StartChallenge(data, challenge, 0, 900001, Lineup));
Assert.Contains(challenge.MazeBuffID, state.Blessings);
Assert.Contains(900001u, state.Blessings);
}
[Fact]
public void ChallengeKindsSplitByIdRange()
{
var data = Load();
if (data is null)
{
return;
}
Assert.All(data.Challenges.Values.Where(c => c.ID < 20000), c => Assert.Equal(ChallengeKind.MemoryOfChaos, c.Kind));
Assert.All(data.Challenges.Values.Where(c => c.ID is > 20000 and < 30000), c => Assert.Equal(ChallengeKind.PureFiction, c.Kind));
Assert.All(data.Challenges.Values.Where(c => c.ID > 30000), c => Assert.Equal(ChallengeKind.ApocalypticShadow, c.Kind));
}
[Fact]
public void EveryAaStageResolvesAScene()
{
var data = Load();
if (data is null)
{
return;
}
var playable = data.ChallengePeaks.Values.Where(p => p.IsPlayable).ToList();
Assert.NotEmpty(playable);
foreach (var peak in playable)
{
data.ChallengePeakBosses.TryGetValue(peak.ID, out var boss);
var state = new ChallengeState();
Assert.True(state.StartPeak(data, peak, boss, 0, Lineup), $"AA {peak.ID}");
Assert.NotEqual(0u, state.StageId);
Assert.NotNull(data.GetStage(state.StageId));
Assert.NotEmpty(state.Blessings);
}
output.WriteLine($"{playable.Count} AA stages all resolve");
}
[Fact]
public void AaHardModeSwapsTagsAndEncounter()
{
var data = Load();
if (data is null)
{
return;
}
var boss = data.ChallengePeakBosses.Values.FirstOrDefault(b => b.HardTagList.Count > 0);
if (boss is null || !data.ChallengePeaks.TryGetValue(boss.ID, out var peak) || !peak.IsPlayable)
{
return;
}
var easy = new ChallengeState();
easy.StartPeak(data, peak, boss, 0, Lineup);
var hard = new ChallengeState { HardMode = true };
hard.StartPeak(data, peak, boss, 0, Lineup);
Assert.NotEqual(easy.Blessings, hard.Blessings);
Assert.All(boss.HardTagList, tag => Assert.Contains(tag, hard.Blessings));
if (boss.HardEventIDList.Count > 0)
{
Assert.Equal(boss.HardEventIDList[^1], hard.StageId);
}
}
[Fact]
public void SceneHasAvatarsAndTheEncounter()
{
var data = Load();
if (data is null)
{
return;
}
var challenge = data.Challenges.Values.First();
var state = new ChallengeState();
Assert.True(state.StartChallenge(data, challenge, 0, 0, Lineup));
var scene = new SceneBuilder(data).BuildChallengeScene(state);
Assert.Equal(state.FloorId, scene.FloorId);
Assert.Equal(state.EntryId, scene.EntryId);
Assert.NotEqual(0u, scene.PlaneId);
Assert.Equal(2, scene.EntityGroupList.Count);
var avatars = scene.EntityGroupList[0];
Assert.Equal(Lineup.Length, avatars.EntityList.Count);
Assert.All(avatars.EntityList, e => Assert.NotNull(e.Actor));
Assert.Equal(avatars.EntityList[0].EntityId, scene.LeaderEntityId);
var monsters = scene.EntityGroupList[1];
var monster = Assert.Single(monsters.EntityList);
Assert.NotNull(monster.NpcMonster);
Assert.Equal(state.MonsterId, monster.NpcMonster.MonsterId);
Assert.Equal(state.EventId, monster.NpcMonster.EventId);
Assert.Equal(state.GroupId, monsters.GroupId);
}
[Fact]
public void MultipathAvatarsUseTheirBaseIdInScenes()
{
var data = Load();
if (data is null)
{
return;
}
var challenge = data.Challenges.Values.First();
var state = new ChallengeState();
state.StartChallenge(data, challenge, 0, 0, [8010, 1224]);
var scene = new SceneBuilder(data).BuildChallengeScene(state);
var actors = scene.EntityGroupList[0].EntityList.Select(e => e.Actor!.BaseAvatarId).ToList();
Assert.Equal([8001u, 1001u], actors);
}
[Fact]
public void ClearResetsEverything()
{
var data = Load();
if (data is null)
{
return;
}
var state = new ChallengeState();
state.StartChallenge(data, data.Challenges.Values.First(), 0, 0, Lineup);
Assert.True(state.Active);
state.Clear();
Assert.False(state.Active);
Assert.Equal(0u, state.StageId);
Assert.Empty(state.Lineup);
Assert.Empty(state.Blessings);
}
}
public class WorldSceneTests(ITestOutputHelper output)
{
private static GameData? Load()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
return sources.Count == 0 ? null : GameData.Load(sources);
}
[Fact]
public void TrainSceneHasAvatarsAndResolves()
{
var data = Load();
if (data is null)
{
return;
}
var scene = new SceneBuilder(data).BuildWorldScene(1000001, [8002, 1001, 1003, 1004]);
Assert.Equal(1000001u, scene.EntryId);
Assert.Equal(10000u, scene.PlaneId);
Assert.Equal(10000000u, scene.FloorId);
Assert.NotEqual(0u, scene.LeaderEntityId);
var avatars = scene.EntityGroupList[0];
Assert.Equal(4, avatars.EntityList.Count);
Assert.All(avatars.EntityList, e => Assert.NotNull(e.Actor));
// 8002 collapses to the 8001 base id in scenes
Assert.Equal(8001u, avatars.EntityList[0].Actor.BaseAvatarId);
// the train deliberately gets no props back
Assert.Single(scene.EntityGroupList);
}
[Fact]
public void NonTrainSceneGetsItsProps()
{
var data = Load();
if (data is null)
{
return;
}
var scene = new SceneBuilder(data).BuildWorldScene(2000101, [8002]);
Assert.Equal(20001u, scene.PlaneId);
Assert.True(scene.EntityGroupList.Count > 1, "expected prop groups");
output.WriteLine($"entry 2000101: {scene.EntityGroupList.Count} groups, " +
$"{scene.EntityGroupList.Sum(g => g.EntityList.Count)} entities");
}
}
public class StartSceneTests(ITestOutputHelper output)
{
[Fact]
public void StartSceneIsPopulatedAndAvatarsAreNotAtTheOrigin()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
if (sources.Count == 0)
{
return;
}
var data = GameData.Load(sources);
var server = new GameServerConfig();
var scene = new SceneBuilder(data).BuildWorldScene(
server.StartEntryId, [8002, 1001, 1003, 1004], server.StartTeleportId, server.WorldId);
output.WriteLine($"entry {scene.EntryId}: plane {scene.PlaneId}, floor {scene.FloorId}, " +
$"{scene.EntityGroupList.Count} groups, " +
$"{scene.EntityGroupList.Sum(g => g.EntityList.Count)} entities");
Assert.Equal(server.StartEntryId, scene.EntryId);
Assert.NotEqual(0u, scene.PlaneId);
Assert.NotEqual(0u, scene.FloorId);
Assert.NotEqual(0u, scene.LeaderEntityId);
// the environment has to come through, not just the party
Assert.True(scene.EntityGroupList.Count > 1, "start scene has no props or monsters");
var spawn = scene.EntityGroupList[0].EntityList[0].Motion.Pos;
Assert.False(spawn.X == 0 && spawn.Y == 0 && spawn.Z == 0,
"avatars spawned at the origin - the teleport position was not applied");
output.WriteLine($"spawn: {spawn.X},{spawn.Y},{spawn.Z}");
}
}
+591
View File
@@ -0,0 +1,591 @@
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 readonly GameWorld World = new(TestConfig(), NullLogger.Instance);
// 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<KcpGateway>.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<PlayerGetTokenCsReq, PlayerGetTokenScRsp>(new PlayerGetTokenCsReq());
var login = await Client.RequestAsync<PlayerLoginCsReq, PlayerLoginScRsp>(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 => new DataConfig().ResolvedSources.Any(Directory.Exists);
[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<GetAvatarDataCsReq, GetAvatarDataScRsp>(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<GetAllLineupDataCsReq, GetAllLineupDataScRsp>(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<GetCurLineupDataCsReq, GetCurLineupDataScRsp>(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<GetLineupAvatarDataCsReq, GetLineupAvatarDataScRsp>(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<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(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<ReplaceLineupCsReq, ReplaceLineupScRsp>(replace);
Assert.Equal(0u, replaced.Retcode);
var sync = await client.ExpectAsync<SyncLineupNotify>();
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<SceneGroupRefreshScNotify>();
Assert.NotEmpty(refreshes);
Assert.Contains(refreshes.SelectMany(r => r.GroupRefreshList), g => g.GroupId == 0 && g.RefreshEntity.Count > 0);
var join = await client.RequestAsync<JoinLineupCsReq, JoinLineupScRsp>(new JoinLineupCsReq { Index = 0, Slot = 2, OPCJCDKMJEI = c });
Assert.Equal(0u, join.Retcode);
sync = await client.ExpectAsync<SyncLineupNotify>();
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<SwapLineupCsReq, SwapLineupScRsp>(new SwapLineupCsReq { Index = 0, MPIEGFCNBEN = 0, ENBIBCPPLDC = 2 });
Assert.Equal(0u, swap.Retcode);
sync = await client.ExpectAsync<SyncLineupNotify>();
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<ChangeLineupLeaderCsReq, ChangeLineupLeaderScRsp>(new ChangeLineupLeaderCsReq { Slot = 2 });
Assert.Equal(0u, leader.Retcode);
Assert.Equal(2u, leader.Slot);
sync = await client.ExpectAsync<SyncLineupNotify>();
Assert.Equal(2u, sync.Lineup.LeaderSlot);
var quit = await client.RequestAsync<QuitLineupCsReq, QuitLineupScRsp>(new QuitLineupCsReq { Index = 0, BaseAvatarId = b });
Assert.Equal(0u, quit.Retcode);
sync = await client.ExpectAsync<SyncLineupNotify>();
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<SwitchLineupIndexCsReq, SwitchLineupIndexScRsp>(new SwitchLineupIndexCsReq { Index = 3 });
Assert.Equal((uint)Retcode.RetLineupIsEmpty, empty.Retcode);
var named = await client.RequestAsync<SetLineupNameCsReq, SetLineupNameScRsp>(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<ReplaceLineupCsReq, ReplaceLineupScRsp>(fill);
var switched = await client.RequestAsync<SwitchLineupIndexCsReq, SwitchLineupIndexScRsp>(new SwitchLineupIndexCsReq { Index = 3 });
Assert.Equal(0u, switched.Retcode);
var current = await client.RequestAsync<GetCurLineupDataCsReq, GetCurLineupDataScRsp>(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<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(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<SceneCastSkillCsReq, SceneCastSkillScRsp>(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<GetCurBattleInfoCsReq, GetCurBattleInfoScRsp>(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<PVEBattleResultCsReq, PVEBattleResultScRsp>(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<SceneGroupRefreshScNotify>();
Assert.Contains(refresh.GroupRefreshList.SelectMany(g => g.RefreshEntity), e => e.DeleteEntity == monster.EntityId);
var sync = await client.ExpectAsync<SyncLineupNotify>();
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<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(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<GetCurBattleInfoCsReq, GetCurBattleInfoScRsp>(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()
{
var configured = World.Player.BattleConfig;
if (!HasData || 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<StartCocoonStageCsReq, StartCocoonStageScRsp>(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<StartCocoonStageCsReq, StartCocoonStageScRsp>(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<SceneCastSkillCostMpCsReq, SceneCastSkillCostMpScRsp>(new SceneCastSkillCostMpCsReq { CastEntityId = 101001 });
Assert.Equal(0u, cost.Retcode);
var update = await client.ExpectAsync<SceneCastSkillMpUpdateScNotify>();
Assert.Equal(LineupBook.BaseMaxMp - 1, update.Mp);
var entry = new GameServerConfig().StartEntryId;
var enter = await client.RequestAsync<EnterSceneCsReq, EnterSceneScRsp>(new EnterSceneCsReq { EntryId = entry, TeleportId = new GameServerConfig().StartTeleportId });
Assert.Equal(0u, enter.Retcode);
var scene = await client.ExpectAsync<EnterSceneByServerScNotify>();
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<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(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<ReplaceLineupCsReq, ReplaceLineupScRsp>(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<GetChallengeCsReq, GetChallengeScRsp>(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<GetFriendListInfoCsReq, GetFriendListInfoScRsp>(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<PlayerSyncScNotify>();
Assert.Equal(3u, basic.BasicInfo.WorldLevel);
await Say(client, "/mp 2");
var sync = await client.ExpectAsync<SyncLineupNotify>();
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<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(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<SceneCastSkillCsReq, SceneCastSkillScRsp>(cast);
Assert.Equal(1022010u, battle.BattleInfo.StageId);
Assert.Equal(3u, battle.BattleInfo.WorldLevel);
}
private static async Task<string> 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<SendMsgCsReq, SendMsgScRsp>(message);
Assert.Equal(0u, sent.Retcode);
var reply = await client.ExpectAsync<RevcMsgScNotify>();
Assert.Equal(GameServer.Handlers.ChatHandlers.BotUid, reply.SourceUid);
return reply.RecvMessageData.MessageDatas[0].ChatData.MessageText;
}
[Fact]
public async Task MemoryOfChaosRunsBothNodesAndSettles()
{
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<StartChallengeCsReq, StartChallengeScRsp>(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<ChallengeLineupNotify>();
Assert.Equal(ExtraLineupType.LineupChallenge2, lineupNotify.ExtraLineupType);
var node2 = await client.ExpectAsync<EnterSceneByServerScNotify>();
Assert.Equal(second, node2.Lineup.AvatarList.Select(a => a.Id));
Assert.Equal(ExtraLineupType.LineupChallenge2, node2.Lineup.ExtraLineupType);
var cur = await client.RequestAsync<GetCurChallengeCsReq, GetCurChallengeScRsp>(new GetCurChallengeCsReq());
Assert.Equal(ExtraLineupType.LineupChallenge2, cur.CurChallenge.ExtraLineupType);
await FightNodeAsync(client, node2.Scene, second.Count, expectSettle: true);
var settle = await client.ExpectAsync<ChallengeSettleNotify>();
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<GetChallengeCsReq, GetChallengeScRsp>(new GetChallengeCsReq());
var record = list.ChallengeList.First(c => c.ChallengeId == floor.ID);
Assert.Equal(settle.Star, record.Star);
var left = await client.RequestAsync<LeaveChallengeCsReq, LeaveChallengeScRsp>(new LeaveChallengeCsReq());
Assert.Equal(0u, left.Retcode);
var world = await client.ExpectAsync<EnterSceneByServerScNotify>();
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<SceneCastSkillCsReq, SceneCastSkillScRsp>(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<PVEBattleResultCsReq, PVEBattleResultScRsp>(result);
Assert.Equal(BattleEndStatus.BattleEndWin, settled.EndStatus);
await client.PumpAsync(TimeSpan.FromMilliseconds(300));
}
}
+286
View File
@@ -0,0 +1,286 @@
using CapySR.Common;
using CapySR.Data;
using CapySR.Data.Excel;
using Xunit;
using Xunit.Abstractions;
namespace CapySR.Tests;
public class GameDataTests(ITestOutputHelper output)
{
private static GameData? TryLoad(ITestOutputHelper output)
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
if (sources.Count == 0)
{
output.WriteLine("skipping: no game data sources found");
return null;
}
return GameData.Load(sources, output.WriteLine);
}
[Fact]
public void LoadsEveryTable()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
Assert.True(data.Avatars.Count > 80, $"only {data.Avatars.Count} avatars");
Assert.True(data.Stages.Count > 20_000, $"only {data.Stages.Count} stages");
Assert.NotEmpty(data.Monsters);
Assert.NotEmpty(data.Equipment);
}
[Fact]
public void ParsesMarch7th()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
var march = data.GetAvatar(1001);
Assert.NotNull(march);
Assert.Equal("Ice", march.DamageType);
Assert.Equal("Knight", march.AvatarBaseType);
Assert.Equal(120u, march.SpMax);
Assert.Equal(6, march.RankIDList.Count);
var tree = data.MaxedSkillTree(1001).ToList();
Assert.True(tree.Count > 10, $"only {tree.Count} trace points");
Assert.All(tree, p => Assert.True(p.Level > 0));
}
[Fact]
public void ParsesStageMonsterWaves()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
var stage = data.GetStage(1022010);
Assert.NotNull(stage);
Assert.Equal("Cocoon", stage.StageType);
Assert.Equal(16u, stage.Level);
var wave = Assert.Single(stage.MonsterList);
Assert.Equal([8001010, 8001020, 8001010, 8001020], wave);
// obfuscated key names, matched by the '_'-prefixed value
Assert.Equal("1", stage.StageConfigData["_Wave"]);
}
[Fact]
public void BetaOverlayAddsNewCharacters()
{
var data = TryLoad(output);
if (data is null || data.Sources.Count < 2)
{
return;
}
// 1503 Pearl ships in the 4.6 beta dump only, not in prod 4.5.0
var pearl = data.GetAvatar(1503);
Assert.NotNull(pearl);
Assert.Equal("Ice", pearl.DamageType);
Assert.Equal("Elation", pearl.AvatarBaseType);
Assert.True(data.MaxedSkillTree(1503).Count() >= 10);
// prod rows still present and not clobbered by the overlay
Assert.NotNull(data.GetAvatar(1001));
Assert.Equal(100101u, data.DefaultMazeBuffs[1001].BuffId);
}
// 101 is the Space Anchor; the Chap00 boxes are the first MP/HP orbs the game shows
[Fact]
public void PropsAreClassifiedByEntityPath()
{
var data = TryLoad(output);
if (data is null || data.Props.Count == 0)
{
return;
}
Assert.True(data.Props[101].IsSpring);
Assert.True(data.Props[100025].IsMpRecover);
Assert.False(data.Props[100025].IsHpRecover);
Assert.True(data.Props[100024].IsHpRecover);
Assert.True(data.Props[100026].IsDestructible);
Assert.False(data.Props[100026].IsMpRecover || data.Props[100026].IsHpRecover);
Assert.Contains(100026u, data.DestructiblePropIds);
Assert.DoesNotContain(101u, data.DestructiblePropIds);
}
[Fact]
public void OverworldEventsResolveToStages()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
// 30001011 is MOC floor 1's encounter; its event is also its stage
Assert.Equal(30001011u, data.ResolveStageForEvent(30001011, 6));
Assert.Equal(0u, data.ResolveStageForEvent(0, 6));
// a real overworld monster from the start scene resolves at every world level
var monster = data.GetScene(new GameServerConfig().StartEntryId)!.Monsters.First(m => m.EventId != 0);
for (var level = 0u; level <= 6; level++)
{
var stage = data.ResolveStageForEvent(monster.EventId, level);
Assert.NotEqual(0u, stage);
Assert.NotNull(data.GetStage(stage));
}
// calyx and farm rows fall back to the closest lower world level
Assert.NotNull(data.FindCocoon(1001, 6));
Assert.NotNull(data.FindCocoon(1001, 0));
Assert.Null(data.FindCocoon(999999, 6));
Assert.NotNull(data.FindFarmElement(1101, 6));
}
[Fact]
public void ChallengeTablesLoad()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
Assert.True(data.Challenges.Count > 600, $"only {data.Challenges.Count} challenge floors");
Assert.NotEmpty(data.MapEntrances);
Assert.NotEmpty(data.MazePlanes);
var kinds = data.Challenges.Values.GroupBy(c => c.Kind).ToDictionary(g => g.Key, g => g.Count());
foreach (var (kind, count) in kinds)
{
output.WriteLine($"{kind}: {count}");
}
Assert.All(Enum.GetValues<ChallengeKind>(), k => Assert.True(kinds.ContainsKey(k), $"no {k} floors"));
}
// dimbreath's ChallengePeakConfig has no MapEntranceID column at all; the beta dump does.
// whole-row merging would drop it and leave AA with nowhere to teleport to.
[Fact]
public void FieldMergeRecoversBetaOnlyColumns()
{
var data = TryLoad(output);
if (data is null || data.Sources.Count < 2)
{
return;
}
var playable = data.ChallengePeaks.Values.Where(p => p.IsPlayable).ToList();
output.WriteLine($"{playable.Count}/{data.ChallengePeaks.Count} AA stages have scene fields");
Assert.NotEmpty(playable);
// 101 exists in both sources; only the beta one carries MapEntranceID
var first = data.ChallengePeaks[101];
Assert.NotEqual(0u, first.MapEntranceID);
Assert.NotEmpty(first.NpcMonsterIDList);
Assert.NotEmpty(first.TagList);
}
[Fact]
public void EntranceResolvesToAPlane()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
var challenge = data.Challenges.Values.First(c => c.MapEntranceID > 0);
var resolved = data.ResolveEntrance(challenge.MapEntranceID);
Assert.NotNull(resolved);
Assert.NotEqual(0u, resolved.Value.Entrance.FloorID);
Assert.NotNull(resolved.Value.Plane);
Assert.NotEqual(0u, resolved.Value.Plane!.WorldID);
}
[Fact]
public void ResolvesTechniqueAndGlobalBuffs()
{
var data = TryLoad(output);
if (data is null)
{
return;
}
var march = data.DefaultMazeBuffs[1001];
Assert.Equal(100101u, march.BuffId);
Assert.Equal(2u, march.SkillIndex);
// castorice and silver wolf are the only two with a global buff
Assert.Equal(2, data.GlobalBuffs.Count);
Assert.Contains(data.GlobalBuffs, b => b.AvatarID == 1407);
Assert.Contains(data.GlobalBuffs, b => b.AvatarID == 1506);
}
}
public class PathSkillTreeTests(ITestOutputHelper output)
{
// AvatarPathData.point_id is the anchor index ("Point01" -> 1), not the raw point id.
// sending raw point ids gives the client something it cannot resolve.
[Fact]
public void AnchorIndexParsesFromAnchorType()
{
Assert.Equal(1u, new AvatarSkillTreeExcel { AnchorType = "Point01" }.AnchorIndex);
Assert.Equal(12u, new AvatarSkillTreeExcel { AnchorType = "Point12" }.AnchorIndex);
Assert.Equal(0u, new AvatarSkillTreeExcel { AnchorType = "" }.AnchorIndex);
Assert.Equal(0u, new AvatarSkillTreeExcel { AnchorType = "Point" }.AnchorIndex);
}
[Fact]
public void EveryPlayableAvatarHasAnchoredTracePoints()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
if (sources.Count == 0)
{
return;
}
var data = GameData.Load(sources);
var playable = data.Avatars.Keys.Where(id => id is (> 1000 and < 2000) or (>= 8001 and <= 8010)).ToList();
var missing = playable.Where(id => !data.MaxedPathSkillTree(id).Any()).ToList();
output.WriteLine($"{playable.Count} playable avatars, {missing.Count} without anchored trace points");
Assert.Empty(missing);
// anchors are small indices, never raw point ids
foreach (var (anchor, _) in data.MaxedPathSkillTree(1001))
{
Assert.InRange(anchor, 1u, 100u);
}
}
[Fact]
public void MultiPathAvatarsAreKnown()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
if (sources.Count == 0)
{
return;
}
var data = GameData.Load(sources);
Assert.Contains(8001u, data.MultiPathAvatarIds);
Assert.Contains(1001u, data.MultiPathAvatarIds);
Assert.Contains(1224u, data.MultiPathAvatarIds);
Assert.DoesNotContain(1003u, data.MultiPathAvatarIds);
}
}
@@ -0,0 +1,239 @@
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;
namespace CapySR.Tests;
public class GatewayIntegrationTests
{
// one world for the whole class; loading the tables is the slow part
private static readonly GameWorld World =
new(new ServerConfig(), NullLogger.Instance);
[Fact]
public async Task ClientCompletesHandshakeAndLogin()
{
var port = FreeUdpPort();
// Persist off: a test must not read or write the machine's saved profile
var config = new ServerConfig { GameServer = { Host = "127.0.0.1", Port = port, Nickname = "Capybara", Persist = false } };
var handlers = HandlerRegistry.Build(NullLogger.Instance);
var gateway = new KcpGateway(config, handlers, World, NullLogger<KcpGateway>.Instance);
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var server = gateway.RunAsync(cancellation.Token);
using var client = new TestClient(new IPEndPoint(IPAddress.Loopback, port));
await client.ConnectAsync();
var token = await client.RequestAsync<PlayerGetTokenCsReq, PlayerGetTokenScRsp>(new PlayerGetTokenCsReq());
Assert.Equal(0u, token.Retcode);
Assert.Equal(config.GameServer.Uid, token.Uid);
var login = await client.RequestAsync<PlayerLoginCsReq, PlayerLoginScRsp>(
new PlayerLoginCsReq { LoginRandom = 4242 });
Assert.Equal(0u, login.Retcode);
Assert.Equal(4242u, login.LoginRandom);
Assert.Equal("Capybara", login.BasicInfo.Nickname);
Assert.Equal(6u, login.BasicInfo.WorldLevel);
var heartbeat = await client.RequestAsync<PlayerHeartBeatCsReq, PlayerHeartBeatScRsp>(
new PlayerHeartBeatCsReq { ClientTimeMs = 1234 });
Assert.Equal(1234u, heartbeat.ClientTimeMs);
await cancellation.CancelAsync();
await Task.WhenAny(server, Task.Delay(2000));
}
[Fact]
public async Task UnmodelledRequestStillGetsAnswered()
{
var port = FreeUdpPort();
var config = new ServerConfig { GameServer = { Host = "127.0.0.1", Port = port, Persist = false } };
var handlers = HandlerRegistry.Build(NullLogger.Instance);
var gateway = new KcpGateway(config, handlers, World, NullLogger<KcpGateway>.Instance);
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var server = gateway.RunAsync(cancellation.Token);
using var client = new TestClient(new IPEndPoint(IPAddress.Loopback, port));
await client.ConnectAsync();
var response = await client.RequestAsync<GetBagCsReq, GetBagScRsp>(new GetBagCsReq());
Assert.NotNull(response);
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;
}
}
internal sealed class TestClient(IPEndPoint server) : IDisposable
{
private readonly Socket _socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private readonly List<NetPacket> _inbox = [];
private Kcp.Kcp? _kcp;
public async Task ConnectAsync()
{
var connect = new NetOperation(NetOperation.ConnectHead, 0, 0, 1234, NetOperation.ConnectTail);
await _socket.SendToAsync(connect.ToArray(), SocketFlags.None, server);
var buffer = new byte[1500];
var result = await ReceiveAsync(buffer, TimeSpan.FromSeconds(5));
Assert.True(NetOperation.TryRead(buffer.AsSpan(0, result), out var accept));
Assert.Equal(NetOperation.AcceptHead, accept.Head);
Assert.Equal(NetOperation.AcceptTail, accept.Tail);
_kcp = new Kcp.Kcp(accept.Param1, accept.Param2, data =>
_socket.SendTo(data, SocketFlags.None, server));
_kcp.SetNoDelay(true, 10, 2, true);
_kcp.SetWindowSize(256, 256);
}
private uint _clock;
private readonly List<string> _arrival = [];
// notifies the server pushed alongside the responses, in arrival order
public IReadOnlyList<NetPacket> Inbox => _inbox;
// packet names exactly as they came off the wire, so ordering can be asserted
public IReadOnlyList<string> ArrivalOrder => _arrival;
public async Task<TResponse> RequestAsync<TRequest, TResponse>(TRequest request)
where TRequest : Google.Protobuf.IMessage<TRequest>
where TResponse : Google.Protobuf.IMessage<TResponse>, new()
{
var expected = ProtocolRegistry.GetCmdId<TResponse>();
_kcp!.Send(NetPacket.Create(request).ToArray());
_kcp.Flush();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
foreach (var packet in _inbox)
{
if (packet.CmdId == expected)
{
_inbox.Remove(packet);
return packet.ParseBody<TResponse>();
}
}
await PumpOnceAsync(TimeSpan.FromMilliseconds(200));
}
throw new TimeoutException($"never received {ProtocolRegistry.GetName(expected)}");
}
// keep reading for a while so pushes that trail a response land in the inbox
public async Task PumpAsync(TimeSpan duration)
{
var deadline = DateTime.UtcNow + duration;
while (DateTime.UtcNow < deadline)
{
await PumpOnceAsync(TimeSpan.FromMilliseconds(50));
}
}
public List<T> Take<T>() where T : Google.Protobuf.IMessage<T>, new()
{
var cmdId = ProtocolRegistry.GetCmdId<T>();
var taken = new List<T>();
foreach (var packet in _inbox.Where(p => p.CmdId == cmdId).ToList())
{
taken.Add(packet.ParseBody<T>());
_inbox.Remove(packet);
}
return taken;
}
public async Task<T> ExpectAsync<T>(TimeSpan? timeout = null) where T : Google.Protobuf.IMessage<T>, new()
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (DateTime.UtcNow < deadline)
{
var found = Take<T>();
if (found.Count > 0)
{
return found[0];
}
await PumpOnceAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"never received {typeof(T).Name}");
}
public void ClearInbox()
{
_inbox.Clear();
_arrival.Clear();
}
private async Task PumpOnceAsync(TimeSpan wait)
{
var buffer = new byte[1500];
var received = await ReceiveAsync(buffer, wait);
if (received > 0)
{
_kcp!.Input(buffer.AsSpan(0, received));
while (_kcp.TryReceive(out var stream))
{
var offset = 0;
while (NetPacket.TryRead(stream.AsSpan(offset), out var packet, out var consumed))
{
_inbox.Add(packet);
_arrival.Add(packet.Name);
offset += consumed;
}
}
}
_clock += 20;
_kcp!.Update(_clock);
}
private async Task<int> ReceiveAsync(byte[] buffer, TimeSpan timeout)
{
using var cancellation = new CancellationTokenSource(timeout);
try
{
var result = await _socket.ReceiveFromAsync(
buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), cancellation.Token);
return result.ReceivedBytes;
}
catch (OperationCanceledException)
{
return 0;
}
}
public void Dispose() => _socket.Dispose();
}
+93
View File
@@ -0,0 +1,93 @@
using CapySR.Kcp;
namespace CapySR.Tests;
// two kcp endpoints wired together over a virtual clock, with optional loss/reorder
public sealed class KcpLink
{
private readonly List<byte[]> _towardsServer = [];
private readonly List<byte[]> _towardsClient = [];
private readonly Random _random;
public KcpLink(uint conversation = 1, uint token = 0xC0FFEE, int seed = 1234)
{
_random = new Random(seed);
Client = new Kcp.Kcp(conversation, token, data => _towardsServer.Add(data.ToArray()));
Server = new Kcp.Kcp(conversation, token, data => _towardsClient.Add(data.ToArray()));
foreach (var kcp in new[] { Client, Server })
{
kcp.SetNoDelay(true, 10, 2, true);
kcp.SetWindowSize(256, 256);
}
}
public Kcp.Kcp Client { get; }
public Kcp.Kcp Server { get; }
public double LossRate { get; set; }
public bool Reorder { get; set; }
public uint Clock { get; private set; }
public int DroppedDatagrams { get; private set; }
public void Pump(int steps, int stepMs = 10)
{
for (var i = 0; i < steps; i++)
{
Clock += (uint)stepMs;
Client.Update(Clock);
Server.Update(Clock);
Deliver(_towardsServer, Server);
Deliver(_towardsClient, Client);
}
}
private void Deliver(List<byte[]> queue, Kcp.Kcp destination)
{
if (queue.Count == 0)
{
return;
}
var batch = queue.ToList();
queue.Clear();
if (Reorder && batch.Count > 1)
{
batch.Reverse();
}
foreach (var datagram in batch)
{
if (LossRate > 0 && _random.NextDouble() < LossRate)
{
DroppedDatagrams++;
continue;
}
destination.Input(datagram);
}
}
public List<byte[]> DrainServer() => Drain(Server);
public List<byte[]> DrainClient() => Drain(Client);
private static List<byte[]> Drain(Kcp.Kcp kcp)
{
var received = new List<byte[]>();
while (kcp.TryReceive(out var data))
{
received.Add(data);
}
return received;
}
}
+123
View File
@@ -0,0 +1,123 @@
using Xunit;
namespace CapySR.Tests;
public class KcpTests
{
[Fact]
public void DeliversSmallMessage()
{
var link = new KcpLink();
var payload = "hello capybara"u8.ToArray();
link.Client.Send(payload);
link.Pump(20);
var received = link.DrainServer();
Assert.Single(received);
Assert.Equal(payload, received[0]);
}
[Fact]
public void ReassemblesFragmentedMessage()
{
var link = new KcpLink();
var payload = RandomBytes(100_000, seed: 7);
link.Client.Send(payload);
link.Pump(400);
var received = link.DrainServer();
Assert.Single(received);
Assert.Equal(payload, received[0]);
}
[Fact]
public void PreservesOrderAcrossManyMessages()
{
var link = new KcpLink();
var sent = new List<byte[]>();
for (var i = 0; i < 50; i++)
{
var payload = RandomBytes(300 + (i * 37), seed: i);
sent.Add(payload);
link.Client.Send(payload);
}
link.Pump(400);
var received = link.DrainServer();
Assert.Equal(sent.Count, received.Count);
for (var i = 0; i < sent.Count; i++)
{
Assert.Equal(sent[i], received[i]);
}
}
[Fact]
public void RecoversFromPacketLoss()
{
var link = new KcpLink { LossRate = 0.25 };
var payload = RandomBytes(40_000, seed: 11);
link.Client.Send(payload);
link.Pump(2000);
var received = link.DrainServer();
Assert.True(link.DroppedDatagrams > 0, "expected the link to actually drop something");
Assert.Single(received);
Assert.Equal(payload, received[0]);
}
[Fact]
public void RecoversFromReordering()
{
var link = new KcpLink { Reorder = true };
var payload = RandomBytes(40_000, seed: 13);
link.Client.Send(payload);
link.Pump(1000);
var received = link.DrainServer();
Assert.Single(received);
Assert.Equal(payload, received[0]);
}
[Fact]
public void CarriesTrafficBothWays()
{
var link = new KcpLink { LossRate = 0.1 };
var request = RandomBytes(5_000, seed: 17);
var response = RandomBytes(9_000, seed: 19);
link.Client.Send(request);
link.Server.Send(response);
link.Pump(1000);
Assert.Equal(request, Assert.Single(link.DrainServer()));
Assert.Equal(response, Assert.Single(link.DrainClient()));
}
[Fact]
public void ReadsConversationFromDatagram()
{
var captured = Array.Empty<byte>();
var kcp = new Kcp.Kcp(0xABCDEF, 0x1234, data => captured = data.ToArray());
kcp.Update(0);
kcp.Send("x"u8.ToArray());
kcp.Update(100);
Assert.NotEmpty(captured);
Assert.Equal(0xABCDEFu, Kcp.Kcp.GetConversation(captured));
}
private static byte[] RandomBytes(int length, int seed)
{
var buffer = new byte[length];
new Random(seed).NextBytes(buffer);
return buffer;
}
}
+234
View File
@@ -0,0 +1,234 @@
using CapySR.GameServer.Game;
using CapySR.Protocol;
using Xunit;
namespace CapySR.Tests;
public class LineupTests
{
private static Lineup Team(params uint[] ids)
{
var lineup = new Lineup();
lineup.Fill(ids);
return lineup;
}
[Fact]
public void FillTakesTheFirstFour()
{
var lineup = Team(1001, 1002, 1003, 1004, 1005);
Assert.Equal([1001u, 1002u, 1003u, 1004u], lineup.Slots);
Assert.Equal(4, lineup.Count);
Assert.Equal(1001u, lineup.LeaderAvatarId);
Assert.Equal(0u, lineup.LeaderSlot);
}
[Fact]
public void JoinPlacesIntoASlot()
{
var lineup = Team(1001);
Assert.Equal(Retcode.RetSucc, lineup.Join(1002, 2));
Assert.Equal(1002u, lineup.Slots[2]);
Assert.Equal(2, lineup.Count);
Assert.Equal([1001u, 1002u], lineup.AvatarIds);
}
[Fact]
public void JoiningTwiceMovesRatherThanDuplicates()
{
var lineup = Team(1001, 1002, 1003);
Assert.Equal(Retcode.RetSucc, lineup.Join(1003, 0));
Assert.Equal(1, lineup.Slots.Count(id => id == 1003));
Assert.Equal(1003u, lineup.Slots[0]);
// whatever was in slot 0 moved to where 1003 came from
Assert.Equal(1001u, lineup.Slots[2]);
}
[Fact]
public void JoiningTheSameSlotIsRejected()
{
var lineup = Team(1001, 1002);
Assert.Equal(Retcode.RetLineupAvatarAlreadyIn, lineup.Join(1002, 1));
Assert.Equal(Retcode.RetLineupInvalidMemberPos, lineup.Join(1003, 4));
}
[Fact]
public void QuitRemovesButNeverEmptiesTheTeam()
{
var lineup = Team(1001, 1002);
Assert.Equal(Retcode.RetSucc, lineup.Quit(1002));
Assert.Equal(1, lineup.Count);
Assert.Equal(Retcode.RetLineupOnlyOneMember, lineup.Quit(1001));
Assert.Equal(1, lineup.Count);
Assert.Equal(Retcode.RetLineupAvatarNotExist, lineup.Quit(1009));
}
[Fact]
public void SwapExchangesSlotsAndTheLeaderFollows()
{
var lineup = Team(1001, 1002, 1003, 1004);
Assert.Equal(Retcode.RetSucc, lineup.Swap(0, 3));
Assert.Equal(1004u, lineup.Slots[0]);
Assert.Equal(1001u, lineup.Slots[3]);
Assert.Equal(1001u, lineup.LeaderAvatarId);
Assert.Equal(3u, lineup.LeaderSlot);
Assert.Equal(Retcode.RetLineupSwapSameSlot, lineup.Swap(1, 1));
}
[Fact]
public void ReplaceSetsSlotsAndLeader()
{
var lineup = new Lineup();
lineup.Replace([(0u, 1005u), (2u, 1006u)], leaderSlot: 2);
Assert.Equal(1005u, lineup.Slots[0]);
Assert.Equal(0u, lineup.Slots[1]);
Assert.Equal(1006u, lineup.Slots[2]);
Assert.Equal(1006u, lineup.LeaderAvatarId);
Assert.Equal(2u, lineup.LeaderSlot);
Assert.Equal(1u, lineup.LeaderIndex);
}
[Fact]
public void ReplaceDropsDuplicates()
{
var lineup = new Lineup();
lineup.Replace([(0u, 1005u), (1u, 1005u), (2u, 1006u)], leaderSlot: 0);
Assert.Equal([1005u, 0u, 1006u, 0u], lineup.Slots);
}
[Fact]
public void LeaderMustPointAtSomeone()
{
var lineup = Team(1001, 1002);
Assert.Equal(Retcode.RetSucc, lineup.SetLeader(1));
Assert.Equal(1002u, lineup.LeaderAvatarId);
// removing the leader moves it to whoever is left
lineup.Quit(1002);
Assert.Equal(1001u, lineup.LeaderAvatarId);
Assert.Equal(0u, lineup.LeaderSlot);
Assert.Equal(Retcode.RetLineupNotValidLeader, lineup.SetLeader(3));
Assert.Equal(Retcode.RetLineupNotValidLeader, lineup.SetLeader(1));
}
[Fact]
public void ReplaceWithAHoleKeepsTheLeaderValid()
{
var lineup = new Lineup();
lineup.Replace([(1u, 1007u)], leaderSlot: 0);
Assert.Equal(1007u, lineup.LeaderAvatarId);
Assert.Equal(1u, lineup.LeaderSlot);
Assert.Equal(1007u, lineup.Slots[1]);
}
[Fact]
public void OccupiedSkipsHoles()
{
var lineup = new Lineup();
lineup.Replace([(0u, 1001u), (3u, 1004u)], leaderSlot: 0);
Assert.Equal([(0u, 1001u), (3u, 1004u)], lineup.Occupied);
Assert.Equal([1001u, 1004u], lineup.AvatarIds);
}
[Fact]
public void PruneDropsWhatIsNoLongerOwned()
{
var lineup = Team(1001, 1002, 1003);
lineup.SetLeader(2);
Assert.True(lineup.Prune(id => id != 1003));
Assert.Equal([1001u, 1002u, 0u, 0u], lineup.Slots);
Assert.Equal(1001u, lineup.LeaderAvatarId);
Assert.False(lineup.Prune(_ => true));
}
}
public class LineupBookTests
{
[Fact]
public void SeedFillsOnlyTheFirstSquadOnce()
{
var book = new LineupBook();
book.Seed([1001, 1002, 1003, 1004, 1005]);
Assert.Equal([1001u, 1002u, 1003u, 1004u], book.Squad(0).Slots);
Assert.True(book.Squad(1).IsEmpty);
book.Seed([1009]);
Assert.Equal(1001u, book.Squad(0).Slots[0]);
}
[Fact]
public void SwitchingNeedsANonEmptySquad()
{
var book = new LineupBook();
book.Seed([1001, 1002]);
Assert.Equal(Retcode.RetLineupIsEmpty, book.SetCurrent(1));
Assert.Equal(Retcode.RetLineupInvalidIndex, book.SetCurrent(9));
book.Squad(1).Fill([1003]);
Assert.Equal(Retcode.RetSucc, book.SetCurrent(1));
Assert.Same(book.Squad(1), book.Current);
}
[Fact]
public void ExtraLineupsTakeOverWhileActive()
{
var book = new LineupBook();
book.Seed([1001, 1002]);
var challenge = book.SetExtra(ExtraLineupType.LineupChallenge, [1003, 1004]);
Assert.True(book.InExtraLineup);
Assert.Same(challenge, book.Current);
Assert.Equal([1003u, 1004u], book.Current.AvatarIds);
book.ClearExtra();
Assert.False(book.InExtraLineup);
Assert.Equal([1001u, 1002u], book.Current.AvatarIds);
}
[Fact]
public void TechniquePointsAreSharedAndCapped()
{
var book = new LineupBook();
book.Seed([1001, 1002]);
Assert.Equal(LineupBook.BaseMaxMp, book.MaxMp);
Assert.True(book.SpendMp());
Assert.Equal(LineupBook.BaseMaxMp - 1, book.Mp);
book.GainMp(10);
Assert.Equal(LineupBook.BaseMaxMp, book.Mp);
// Phainon raises the cap while he is in the team
book.Squad(0).Join(1408, 2);
Assert.Equal(LineupBook.BaseMaxMp + 3, book.MaxMp);
book.RefillMp();
Assert.Equal(LineupBook.BaseMaxMp + 3, book.Mp);
book.Squad(0).Quit(1408);
Assert.Equal(LineupBook.BaseMaxMp, book.Mp);
}
}
+105
View File
@@ -0,0 +1,105 @@
using CapySR.Protocol;
using Xunit;
namespace CapySR.Tests;
public class ProtocolTests
{
[Fact]
public void RegistryResolvesEveryCmdId()
{
ProtocolRegistry.EnsureInitialized();
Assert.True(ProtocolRegistry.Count > 2000);
}
[Theory]
[InlineData(34, "PlayerLoginCsReq")]
[InlineData(60, "PlayerGetTokenCsReq")]
[InlineData(134, "PVEBattleResultCsReq")]
[InlineData(125, "PVEBattleResultScRsp")]
public void CmdIdsMatchTheDump(ushort id, string name)
{
Assert.Equal(name, ProtocolRegistry.GetName(id));
}
[Fact]
public void CmdIdLookupIsTwoWay()
{
Assert.Equal(34, ProtocolRegistry.GetCmdId<PlayerLoginCsReq>());
Assert.Equal(34u, (uint)(ProtocolRegistry.GetCmdId(new PlayerLoginCsReq()) ?? 0));
Assert.Equal((ushort)CmdId.PlayerLoginCsReq, ProtocolRegistry.GetCmdId<PlayerLoginCsReq>());
}
[Fact]
public void PacketRoundTrips()
{
var login = new PlayerLoginScRsp
{
LoginRandom = 12345,
Stamina = 240,
BasicInfo = new PlayerBasicInfo { Nickname = "Capy", Level = 70, WorldLevel = 6 },
};
var encoded = NetPacket.Create(login).ToArray();
Assert.True(NetPacket.TryRead(encoded, out var packet, out var consumed));
Assert.Equal(encoded.Length, consumed);
Assert.Equal(ProtocolRegistry.GetCmdId<PlayerLoginScRsp>(), packet.CmdId);
var decoded = packet.ParseBody<PlayerLoginScRsp>();
Assert.Equal(12345u, decoded.LoginRandom);
Assert.Equal("Capy", decoded.BasicInfo.Nickname);
}
[Fact]
public void ReadsBackToBackPackets()
{
var first = NetPacket.Create(new PlayerHeartBeatScRsp { ClientTimeMs = 1 }).ToArray();
var second = NetPacket.Empty(ProtocolRegistry.GetCmdId<GetBagScRsp>()).ToArray();
var stream = new byte[first.Length + second.Length];
first.CopyTo(stream, 0);
second.CopyTo(stream, first.Length);
Assert.True(NetPacket.TryRead(stream, out var a, out var consumedA));
Assert.True(NetPacket.TryRead(stream.AsSpan(consumedA), out var b, out _));
Assert.Equal(ProtocolRegistry.GetCmdId<PlayerHeartBeatScRsp>(), a.CmdId);
Assert.Equal(ProtocolRegistry.GetCmdId<GetBagScRsp>(), b.CmdId);
Assert.Empty(b.Body);
}
[Fact]
public void RejectsMalformedPackets()
{
Assert.False(NetPacket.TryRead([1, 2, 3], out _, out _));
var good = NetPacket.Create(new PlayerHeartBeatScRsp()).ToArray();
var badHead = good.ToArray();
badHead[0] ^= 0xFF;
Assert.False(NetPacket.TryRead(badHead, out _, out _));
var badTail = good.ToArray();
badTail[^1] ^= 0xFF;
Assert.False(NetPacket.TryRead(badTail, out _, out _));
Assert.False(NetPacket.TryRead(good.AsSpan(0, good.Length - 1), out _, out _));
}
[Fact]
public void HandshakeOperationRoundTrips()
{
var accept = NetOperation.Accept(conversation: 7, token: 0xDEAD, echo: 99);
Assert.True(NetOperation.TryRead(accept.ToArray(), out var parsed));
Assert.Equal(accept, parsed);
Assert.Equal(NetOperation.Size, accept.ToArray().Length);
var connect = new NetOperation(
NetOperation.ConnectHead, 0, 0, 1234, NetOperation.ConnectTail);
Assert.True(connect.IsConnect);
Assert.False(connect.IsDisconnect);
}
}
+200
View File
@@ -0,0 +1,200 @@
using CapySR.Common;
using CapySR.Data;
using CapySR.Data.SrTools;
using CapySR.GameServer.Battle;
using CapySR.GameServer.Game;
using Xunit;
using Xunit.Abstractions;
namespace CapySR.Tests;
// runs against a real freesr-data.json when one is present
public class SrToolsTests(ITestOutputHelper output)
{
private static SrToolsData? Load(ITestOutputHelper output)
{
var path = SrToolsData.ResolvePath(RepoPaths.Root);
if (!File.Exists(path))
{
output.WriteLine($"skipping: no {path}");
return null;
}
output.WriteLine($"loaded {path}");
return SrToolsData.Load(path);
}
[Fact]
public void ParsesRealExport()
{
var player = Load(output);
if (player is null)
{
return;
}
output.WriteLine($"{player.Avatars.Count} avatars, {player.Relics.Count} relics, " +
$"{player.Lightcones.Count} lightcones, stage {player.BattleConfig.StageId}");
Assert.NotEmpty(player.Avatars);
Assert.NotEmpty(player.Relics);
Assert.NotEmpty(player.Lightcones);
// dictionary keys arrive as json strings
Assert.All(player.Avatars, kv => Assert.Equal(kv.Key, kv.Value.AvatarId));
}
[Fact]
public void ReadsTechniquesNotUseTechnique()
{
var player = Load(output);
if (player is null)
{
return;
}
var withTechniques = player.Avatars.Values.Count(a => a.Techniques.Count > 0);
output.WriteLine($"{withTechniques}/{player.Avatars.Count} avatars carry technique buffs");
Assert.True(withTechniques > player.Avatars.Count / 2,
$"only {withTechniques} avatars had techniques - is the json field name still 'techniques'?");
}
[Fact]
public void TracesAndGearSurvive()
{
var player = Load(output);
if (player is null)
{
return;
}
Assert.All(player.Avatars.Values, a => Assert.NotEmpty(a.Data.Skills));
Assert.Contains(player.Avatars.Values, a => a.Data.Rank > 0);
// relic and lightcone unique ids must not collide
var relicIds = player.Relics.Select(r => r.UniqueId).ToHashSet();
var lightconeIds = player.Lightcones.Select(l => l.UniqueId).ToHashSet();
Assert.Empty(relicIds.Intersect(lightconeIds));
Assert.Contains(player.Relics, r => r.EquipAvatar != 0 && r.SubAffixes.Count > 0);
Assert.Contains(player.Lightcones, l => l.EquipAvatar != 0);
}
[Fact]
public void BuildsABattleFromTheRealExport()
{
var player = Load(output);
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
if (player is null || sources.Count == 0)
{
return;
}
var data = GameData.Load(sources);
var config = player.BattleConfig;
var lineup = player.Avatars.Keys.Where(Roster.IsPlayable).OrderBy(id => id).Take(4).ToList();
var battle = new BattleBuilder(data).Build(
new BattleRequest
{
StageId = config.StageId,
Lineup = lineup,
Kind = config.Kind,
CycleCount = config.CycleCount == 0 ? 30 : config.CycleCount,
MonsterWaves = config.Monsters is { Count: > 0 } ? config.Monsters : null,
Blessings = config.Blessings,
},
player);
output.WriteLine($"stage {battle.StageId}: {battle.BattleAvatarList.Count} avatars, " +
$"{battle.BuffList.Count} buffs, {battle.MonsterWaveList.Count} waves");
Assert.Equal(lineup.Count, battle.BattleAvatarList.Count);
Assert.All(battle.BattleAvatarList, a => Assert.NotEmpty(a.SkilltreeList));
Assert.NotEmpty(battle.MonsterWaveList);
// srtools' own technique ids must reach the battle
foreach (var avatarId in lineup)
{
foreach (var technique in player.Avatars[avatarId].Techniques)
{
Assert.Contains(battle.BuffList, b => b.Id == technique);
}
}
// and its blessings
foreach (var blessing in config.Blessings)
{
Assert.Contains(battle.BuffList, b => b.Id == blessing.Id);
}
}
[Fact]
public void MonsterAmountIsExpanded()
{
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
if (sources.Count == 0)
{
return;
}
var data = GameData.Load(sources);
var player = new SrToolsData();
player.Avatars[1001] = new SrAvatar { AvatarId = 1001 };
var battle = new BattleBuilder(data).Build(
new BattleRequest
{
StageId = 1022010,
Lineup = [1001],
MonsterWaves = [[new SrMonster { MonsterId = 3014022, Level = 95, Amount = 3 }]],
},
player);
Assert.Equal(3, battle.MonsterWaveList[0].MonsterList.Count);
}
}
public class InventoryIdTests(ITestOutputHelper output)
{
// a unique_id of 0 means "no item"; sending one makes the client's inventory
// red-dot filter NPE every frame and the character screen freezes black
[Fact]
public void NoInventoryItemUsesIdZero()
{
var path = SrToolsData.ResolvePath(RepoPaths.Root);
if (!File.Exists(path))
{
return;
}
var player = SrToolsData.Load(path);
Assert.All(player.Relics, r => Assert.NotEqual(0u, r.UniqueId));
Assert.All(player.Lightcones, l => Assert.NotEqual(0u, l.UniqueId));
}
[Fact]
public void RelicAndLightconeIdRangesDoNotOverlap()
{
var path = SrToolsData.ResolvePath(RepoPaths.Root);
if (!File.Exists(path))
{
return;
}
var player = SrToolsData.Load(path);
var relics = player.Relics.Select(r => r.UniqueId).ToHashSet();
var lightcones = player.Lightcones.Select(l => l.UniqueId).ToHashSet();
output.WriteLine($"relics {relics.Min()}-{relics.Max()}, lightcones {lightcones.Min()}-{lightcones.Max()}");
Assert.Empty(relics.Intersect(lightcones));
Assert.Equal(player.Relics.Count, relics.Count);
Assert.Equal(player.Lightcones.Count, lightcones.Count);
}
}