chore(server/game): remove the old c# server
This commit is contained in:
1 parent
917ab66787
commit
63385b7e87
87 files changed
-1437306
No files matched your search
@@ -1,401 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,324 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
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<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 => 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<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()
|
||||
{
|
||||
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<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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// an empty banner leaves the client's gacha red dot throwing once a frame
|
||||
[Fact]
|
||||
public void ReadsStandardBannerCeiling()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.NotEmpty(data.GachaCeilingAvatars);
|
||||
Assert.Contains(1003u, data.GachaCeilingAvatars);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
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 GameWorld World => TestWorld.Current;
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCompletesHandshakeAndLogin()
|
||||
{
|
||||
if (!TestWorld.Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (!TestWorld.Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using Xunit;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// A beta dispatch stops answering once its window closes, so a client that updated past
|
||||
// everything in config/versions.json has to be served from the closest build on hand.
|
||||
public class GameVersionTests
|
||||
{
|
||||
private static readonly string[] Cached =
|
||||
[
|
||||
"OSBETAWin4.4.51",
|
||||
"CNBETAWin4.4.53",
|
||||
"CNBETAWin4.5.51",
|
||||
"CNBETAWin4.5.52",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void VersionsParseIntoAChannelAndNumbers()
|
||||
{
|
||||
Assert.True(GameVersion.TryParse("CNBETAWin4.5.53", out var version));
|
||||
Assert.Equal("CNBETAWin", version.Channel);
|
||||
Assert.Equal([4, 5, 53], version.Parts);
|
||||
|
||||
Assert.False(GameVersion.TryParse("nonsense", out _));
|
||||
Assert.False(GameVersion.TryParse("CNBETAWin4.5.x", out _));
|
||||
Assert.False(GameVersion.TryParse("", out _));
|
||||
Assert.False(GameVersion.TryParse(null, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CNBETAWin4.5.52", "CNBETAWin4.5.53", -1)]
|
||||
[InlineData("CNBETAWin4.5.53", "CNBETAWin4.5.52", 1)]
|
||||
[InlineData("CNBETAWin4.5.53", "CNBETAWin4.5.53", 0)]
|
||||
[InlineData("CNBETAWin4.9.0", "CNBETAWin4.10.0", -1)]
|
||||
// a missing trailing part counts as zero, so 4.5 and 4.5.0 are the same build
|
||||
[InlineData("CNBETAWin4.5", "CNBETAWin4.5.0", 0)]
|
||||
public void VersionsCompareNumerically(string left, string right, int expected)
|
||||
{
|
||||
Assert.True(GameVersion.TryParse(left, out var a));
|
||||
Assert.True(GameVersion.TryParse(right, out var b));
|
||||
Assert.Equal(expected, Math.Sign(a.CompareTo(b)));
|
||||
}
|
||||
|
||||
// the case that matters: the client updated past anything the dispatch will still serve
|
||||
[Fact]
|
||||
public void ANewerClientBorrowsTheClosestOlderBuild()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin4.5.53", out var nearest));
|
||||
Assert.Equal("CNBETAWin4.5.52", nearest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChannelsNeverBorrowFromEachOther()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "OSBETAWin4.9.99", out var os));
|
||||
Assert.Equal("OSBETAWin4.4.51", os);
|
||||
|
||||
Assert.False(GameVersion.TryFindNearest(Cached, "CNPRODWin4.5.53", out _));
|
||||
Assert.False(GameVersion.TryFindNearest(Cached, "nonsense", out _));
|
||||
Assert.False(GameVersion.TryFindNearest([], "CNBETAWin4.5.53", out _));
|
||||
}
|
||||
|
||||
// nothing older to fall back on, so the newest known build beats having no urls at all
|
||||
[Fact]
|
||||
public void AnOlderClientStillGetsUrls()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin3.0.0", out var nearest));
|
||||
Assert.Equal("CNBETAWin4.5.52", nearest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnExactMatchIsPreferredOverEverythingElse()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin4.5.51", out var nearest));
|
||||
Assert.Equal("CNBETAWin4.5.51", nearest);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Game;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// The game data is a separate checkout, so a fresh clone has none. Tests that need it check
|
||||
// Available and return instead of failing. Building the world is deferred for the same
|
||||
// reason, and shared because loading the tables is the slow part of the suite.
|
||||
internal static class TestWorld
|
||||
{
|
||||
private static readonly Lazy<GameWorld> Instance =
|
||||
new(() => new GameWorld(new ServerConfig(), NullLogger.Instance));
|
||||
|
||||
public static bool Available { get; } = new DataConfig().ResolvedSources.Any(Directory.Exists);
|
||||
|
||||
public static GameWorld Current => Instance.Value;
|
||||
}
|
||||
Reference in new issue
Block a user