Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion AscNet.GameServer/Handlers/CurrentClientStudyTables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using AscNet.Table.V2.share.fuben;
using AscNet.Table.V2.share.robot;
using Newtonsoft.Json.Linq;
using System.Reflection;

namespace AscNet.GameServer.Handlers;

Expand All @@ -16,6 +17,8 @@ internal static class CurrentClientStudyTables
internal const int StudyStageCount = 467;
internal const int StageLevelControlCount = 141;
internal const int RobotCount = 170;
internal const int EnhanceSkillCount = 82;
internal const int EnhanceSkillGroupCount = 92;
private const int ProgressionEdgeCount = 255;
private const int ProgressionChainCount = 212;

Expand Down Expand Up @@ -62,6 +65,9 @@ internal static bool TryGetRobot(int robotId, out RobotTable robot)
return Data.Value.Robots.TryGetValue(robotId, out robot!);
}

/// Frozen 4.6 enhance-skill inputs of the imported Study robots, matching their frozen Robot rows.
internal static EnhanceSkillSource EnhanceSkills => Data.Value.EnhanceSkills;

internal static bool TryGetPracticeChapterId(long stageId, out int chapterId)
{
if (!TryGetStageKey(stageId, out int key))
Expand Down Expand Up @@ -112,7 +118,7 @@ private static Catalog Load()

JObject sourcePaths = RequireObject(root, "SourcePaths");
JObject sourceHashes = RequireObject(root, "SourceHashes");
foreach (string source in new[] { "Stage", "StageLevelControl", "Robot", "PracticeChapter", "PracticeGroup", "PracticeActivity", "TeachingActivity", "TeachingRobot" })
foreach (string source in new[] { "Stage", "StageLevelControl", "Robot", "PracticeChapter", "PracticeGroup", "PracticeActivity", "TeachingActivity", "TeachingRobot", "EnhanceSkill", "EnhanceSkillGroup" })
{
string? path = sourcePaths.Value<string>(source);
if (string.IsNullOrWhiteSpace(path) || !path.StartsWith("en/bytes/", StringComparison.Ordinal) || !path.EndsWith(".json", StringComparison.Ordinal))
Expand All @@ -131,6 +137,8 @@ private static Catalog Load()
ValidateDeclaredCount(expectedCounts, "StudyStages", StudyStageCount);
ValidateDeclaredCount(expectedCounts, "StageLevelControls", StageLevelControlCount);
ValidateDeclaredCount(expectedCounts, "Robots", RobotCount);
ValidateDeclaredCount(expectedCounts, "EnhanceSkills", EnhanceSkillCount);
ValidateDeclaredCount(expectedCounts, "EnhanceSkillGroups", EnhanceSkillGroupCount);

JArray practiceChapters = RequireArray(root, "PracticeChapters", PracticeChapterCount);
JArray practiceGroups = RequireArray(root, "PracticeGroups", PracticeGroupCount);
Expand All @@ -140,6 +148,8 @@ private static Catalog Load()
JArray stageRows = RequireArray(root, "Stages", StudyStageCount);
JArray stageLevelControlRows = RequireArray(root, "StageLevelControls", StageLevelControlCount);
JArray robotRows = RequireArray(root, "Robots", RobotCount);
JArray enhanceSkillRows = RequireArray(root, "EnhanceSkills", EnhanceSkillCount);
JArray enhanceSkillGroupRows = RequireArray(root, "EnhanceSkillGroups", EnhanceSkillGroupCount);

HashSet<int> studyStageIds = new();
foreach (JObject row in practiceGroups.OfType<JObject>())
Expand Down Expand Up @@ -242,6 +252,32 @@ private static Catalog Load()
if (robots.Values.Any(robot => robot.CharacterId <= 0))
throw new InvalidDataException($"{ResourcePath}: every imported Robot must define a CharacterId.");

Dictionary<int, int[]> enhanceSkillGroupIds = new();
foreach (JObject row in enhanceSkillRows.OfType<JObject>())
{
int characterId = row.Value<int>("CharacterId");
if (characterId <= 0 || !enhanceSkillGroupIds.TryAdd(characterId, ReadPositiveIds(row["SkillGroupId"])))
throw new InvalidDataException($"{ResourcePath}: invalid or duplicate EnhanceSkill CharacterId {characterId}.");
}
if (!enhanceSkillGroupIds.Keys.ToHashSet().SetEquals(robots.Values.Select(robot => robot.CharacterId)))
throw new InvalidDataException($"{ResourcePath}: EnhanceSkills must cover exactly the imported Robot characters.");

Dictionary<int, int[]> enhanceSkillGroupSkills = new();
foreach (JObject row in enhanceSkillGroupRows.OfType<JObject>())
{
int groupId = row.Value<int>("Id");
if (groupId <= 0 || !enhanceSkillGroupSkills.TryAdd(groupId, ReadPositiveIds(row["SkillId"])))
throw new InvalidDataException($"{ResourcePath}: invalid or duplicate EnhanceSkillGroup Id {groupId}.");
}
foreach ((int characterId, int[] groupIds) in enhanceSkillGroupIds)
{
foreach (int groupId in groupIds)
{
if (!enhanceSkillGroupSkills.ContainsKey(groupId))
throw new InvalidDataException($"{ResourcePath}: EnhanceSkill {characterId} references missing EnhanceSkillGroup {groupId}.");
}
}

Dictionary<int, List<StageLevelControlTable>> controls = new();
HashSet<int> controlIds = new();
foreach (JObject token in stageLevelControlRows.OfType<JObject>())
Expand All @@ -262,6 +298,9 @@ private static Catalog Load()
configuredRobotIds,
controls.ToDictionary(pair => pair.Key, pair => pair.Value.ToArray()),
robots,
new EnhanceSkillSource(
characterId => enhanceSkillGroupIds.GetValueOrDefault(characterId, []),
groupId => enhanceSkillGroupSkills.GetValueOrDefault(groupId, [])),
practiceChapterIds,
teachingActivityIds.ToDictionary(pair => pair.Key, pair => pair.Value.Distinct().Order().ToArray()));
}
Expand Down Expand Up @@ -322,13 +361,30 @@ private static Dictionary<int, T> ToUniqueDictionary<T>(JArray rows, Func<T, int
{
T row = token.ToObject<T>()
?? throw new InvalidDataException($"{ResourcePath}: invalid {section} row.");
MaterializeOmittedArrays(row);
int key = keySelector(row);
if (key <= 0 || !result.TryAdd(key, row))
throw new InvalidDataException($"{ResourcePath}: invalid or duplicate {section} key {key}.");
}
return result;
}

/// Client table JSON omits empty arrays, while the runtime TSV reader materializes them and the
/// generated rows declare them non-nullable. Frozen rows adopt the reader-built invariant so
/// Stage and Robot fields are never null.
private static void MaterializeOmittedArrays<T>(T row)
{
foreach (PropertyInfo property in typeof(T).GetProperties())
{
if (property.PropertyType.IsGenericType
&& property.PropertyType.GetGenericTypeDefinition() == typeof(List<>)
&& property.GetValue(row) is null)
{
property.SetValue(row, Activator.CreateInstance(property.PropertyType));
}
}
}

private static void NormalizeBooleanScalars(JObject row)
{
foreach (JProperty property in row.Properties().Where(property => property.Value.Type == JTokenType.Boolean).ToArray())
Expand Down Expand Up @@ -382,6 +438,7 @@ private sealed record Catalog(
IReadOnlyDictionary<int, int[]> ConfiguredRobotIds,
IReadOnlyDictionary<int, StageLevelControlTable[]> StageLevelControls,
IReadOnlyDictionary<int, RobotTable> Robots,
EnhanceSkillSource EnhanceSkills,
IReadOnlyDictionary<int, int> PracticeChapterIds,
IReadOnlyDictionary<int, int[]> TeachingActivityIds);
}
55 changes: 54 additions & 1 deletion AscNet.GameServer/Handlers/FightModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using AscNet.Table.V2.share.partner;
using AscNet.Table.V2.share.team;
using AscNet.Table.V2.share.character;
using AscNet.Table.V2.share.character.enhanceskill;
using AscNet.Table.V2.share.character.skill;
using AscNet.Table.V2.share.fuben;
using AscNet.Table.V2.share.fashion;
Expand Down Expand Up @@ -299,6 +300,21 @@ public class LeaveFightResponse
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
#endregion

/// One client version's enhance-skill inputs: the authored groups of a character and the
/// authored skills of a group. Premade deployments resolve both from the robot row's version.
internal sealed class EnhanceSkillSource(Func<int, IReadOnlyList<int>> groupIds, Func<int, IReadOnlyList<int>> skillIds)
{
// Live tables shipped with the server, used by every deployment from the live Robot table.
internal static EnhanceSkillSource Live { get; } = new(
characterId => TableReaderV2.Parse<EnhanceSkillTable>()
.Find(row => row.CharacterId == characterId)?.SkillGroupId ?? [],
groupId => TableReaderV2.Parse<EnhanceSkillGroupTable>()
.Find(row => row.Id == groupId)?.SkillId ?? []);

internal IReadOnlyList<int> GroupIds(int characterId) => groupIds(characterId);
internal IReadOnlyList<int> SkillIds(int groupId) => skillIds(groupId);
}

internal class FightModule
{
private const int TeamManagerSetTeamParaError = 20004003;
Expand Down Expand Up @@ -768,7 +784,11 @@ public static void PreFightRequestHandler(Session session, Packet.Request packet
while (playerNpcData.ContainsKey(npcKey))
npcKey++;

(CharacterData robotCharacterData, List<EquipData> equips) = BuildRobotDeployment(robot);
// Legacy Study stages deploy version-frozen 4.6 premise rows, so their enhance
// inputs come from the same frozen version instead of the live tables.
(CharacterData robotCharacterData, List<EquipData> equips) = isCurrentStudyStage
? BuildRobotDeployment(robot, CurrentClientStudyTables.EnhanceSkills)
: BuildRobotDeployment(robot);
deployedCharacters.Add(robotCharacterData);
playerNpcData.Add(npcKey, new
{
Expand Down Expand Up @@ -838,6 +858,13 @@ public static void PreFightRequestHandler(Session session, Packet.Request packet
}

internal static (CharacterData Character, List<EquipData> Equips) BuildRobotDeployment(RobotTable robot)
=> BuildRobotDeployment(robot, EnhanceSkillSource.Live);

/// <param name="enhanceSkills">
/// Enhance-skill inputs of the robot row's own client version. Premade rows served from a
/// version-frozen catalog must be deployed with that version's inputs, never the live ones.
/// </param>
internal static (CharacterData Character, List<EquipData> Equips) BuildRobotDeployment(RobotTable robot, EnhanceSkillSource enhanceSkills)
{
CharacterSkillTable? characterSkill = TableReaderV2.Parse<CharacterSkillTable>().Find(x => x.CharacterId == robot.CharacterId);
IEnumerable<int> skills = characterSkill?.SkillGroupId.SelectMany(x => TableReaderV2.Parse<CharacterSkillGroupTable>().Find(y => y.Id == x)?.SkillId ?? new List<int>()) ?? new List<int>();
Expand Down Expand Up @@ -889,6 +916,7 @@ internal static (CharacterData Character, List<EquipData> Equips) BuildRobotDepl
Level = Math.Min(Convert.ToInt32(robot.SkillLevel), TableReaderV2.Parse<CharacterSkillLevelEffectTable>()
.Where(row => row.SkillId == id).Select(row => row.Level).DefaultIfEmpty(1).Max())
}).ToList(),
EnhanceSkillList = BuildRobotEnhanceSkills(robot, enhanceSkills),
FashionId = fashionId,
TrustLv = 1,
Ability = robot.ShowAbility ?? 0,
Expand All @@ -898,6 +926,31 @@ internal static (CharacterData Character, List<EquipData> Equips) BuildRobotDepl
return (data, equips);
}

/// <summary>One active skill per owned enhance group; a removed skill or an absent level yields none.</summary>
internal static List<CharacterSkill> BuildRobotEnhanceSkills(RobotTable robot, EnhanceSkillSource enhanceSkills)
{
List<CharacterSkill> enhanceSkillList = [];
if (robot.EnhanceSkillLevel <= 0)
return enhanceSkillList;

HashSet<int> removedSkillIds = robot.RemoveSkillId?.ToHashSet() ?? [];
foreach (int groupId in enhanceSkills.GroupIds(robot.CharacterId).Where(id => id > 0).Distinct())
{
// The client's XEnhanceSkillGroup activates the group's first configured skill.
int skillId = enhanceSkills.SkillIds(groupId).FirstOrDefault(id => id > 0);
if (skillId <= 0 || removedSkillIds.Contains(skillId))
continue;

int maxLevel = Character.EnhanceSkillMaxLevel(skillId);
enhanceSkillList.Add(new CharacterSkill
{
Id = (uint)skillId,
Level = Math.Clamp(robot.EnhanceSkillLevel, 1, Math.Max(1, maxLevel))
});
}
return enhanceSkillList;
}

private static List<ResonanceInfo> BuildRobotResonance(string? templates, string? types, int characterId)
{
List<ResonanceInfo> result = [];
Expand Down
6 changes: 5 additions & 1 deletion AscNet.Test/Program.Theatre.Combat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,11 @@ private static void ValidateTheatreCombatCompatibility()
test.Data.CurRoleLv = level.Lv;
var robot = TableReaderV2.Parse<RobotTable>().Single(row => row.Id ==
TableReaderV2.Parse<TheatreRoleAttrTable>().Single(row => row.RoleId == test.Data.RecruitRole[0] && row.Lv == level.Lv).RobotId);
var build = RequiredAscNetGameServerType("AscNet.GameServer.Handlers.FightModule").GetMethod("BuildRobotDeployment", BindingFlags.Static | BindingFlags.NonPublic)!;
var build = RequiredMethod(
RequiredAscNetGameServerType("AscNet.GameServer.Handlers.FightModule"),
"BuildRobotDeployment",
BindingFlags.Static | BindingFlags.NonPublic,
[typeof(RobotTable)]);
var deployment = ((CharacterData Character, List<EquipData> Equips))build.Invoke(null, [robot])!;
test.Session.character.Characters.RemoveAll(row => row.Id == deployment.Character.Id);
test.Session.character.Characters.Add(deployment.Character);
Expand Down
68 changes: 68 additions & 0 deletions AscNet.Test/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24100,6 +24100,8 @@ static JArray CompatibilityRows(JObject root, string name) =>
AssertEqual(467, CompatibilityRows(compatibility, "Stages").Count, "Study compatibility Stage row count");
AssertEqual(141, CompatibilityRows(compatibility, "StageLevelControls").Count, "Study compatibility StageLevelControl row count");
AssertEqual(170, CompatibilityRows(compatibility, "Robots").Count, "Study compatibility Robot row count");
AssertEqual(82, CompatibilityRows(compatibility, "EnhanceSkills").Count, "Study compatibility EnhanceSkill row count");
AssertEqual(92, CompatibilityRows(compatibility, "EnhanceSkillGroups").Count, "Study compatibility EnhanceSkillGroup row count");

JArray studyRobotRows = CompatibilityRows(compatibility, "Robots");
JObject StudyRobotRow(int robotId) =>
Expand Down Expand Up @@ -24192,6 +24194,41 @@ JObject StudyRobotRow(int robotId) =>
// Weapon-only sparse shape: authored weapon, no core wafer arrays at all.
AssertStudyRobotEquipPayload(weaponOnlyFight, 1_142, StudyRobotRow(1_142),
"Study weapon-only robot stage 30100883");

PreFightResponse baseWeaveFight = AssertStudyStageRobotDeployment(
stageId: 30_100_971,
cardIds: [],
robotIds: [],
expectedCharacterId: 1_021_005,
expectedRobotId: 9_161,
luciaLotusCharacterId,
"Study base-form Crimson Weave stage 30100971");
AssertRobotDeployedEnhanceSkills(baseWeaveFight, 9_161, [],
"Study base-form Crimson Weave stage 30100971");
PreFightResponse leapWeaveFight = AssertStudyStageRobotDeployment(
stageId: 30_100_099,
cardIds: [],
robotIds: [],
expectedCharacterId: 1_021_005,
expectedRobotId: 9_239,
luciaLotusCharacterId,
"Study Crimson Weave leap trial stage 30100099");
AssertRobotDeployedEnhanceSkills(leapWeaveFight, 9_239,
[(102_531, 18), (102_529, 18), (102_530, 18)],
"Study Crimson Weave leap trial stage 30100099");

PreFightResponse basePyroathFight = AssertStudyStageRobotDeployment(
stageId: 30_100_081,
cardIds: [],
robotIds: [],
expectedCharacterId: 1_021_006,
expectedRobotId: 2_273,
luciaLotusCharacterId,
"Study level-1 Pyroath effect practice stage 30100081");
// 4.6 authors no enhance groups for Pyroath, so the version-frozen robot grants none and
// its authored removal list is already satisfied.
AssertRobotDeployedEnhanceSkills(basePyroathFight, 2_273, [],
"Study level-1 Pyroath effect practice stage 30100081");
}

private static PreFightResponse AssertStudyStageRobotDeployment(
Expand Down Expand Up @@ -24369,6 +24406,37 @@ private static void AssertStudyRobotNpcSlot(
AssertEqual(expectedRobotId, RequiredDynamicInteger(npcData, "RobotId", name), $"{name}.RobotId");
}

private static void AssertRobotDeployedEnhanceSkills(
PreFightResponse preFightResponse,
int robotId,
IReadOnlyList<(int SkillId, int Level)> expected,
string name)
{
if (preFightResponse.FightData is null)
throw new InvalidDataException($"{name}: expected FightData.");
System.Collections.IDictionary npc = preFightResponse.FightData.RoleData
.SelectMany(role => role.NpcData.Values)
.Select(value => RequiredDynamicMap(value, $"{name} NpcData"))
.Single(candidate => RequiredDynamicInteger(candidate, "RobotId", $"{name} NpcData") == robotId);
System.Collections.IDictionary character = RequiredDynamicMap(
RequiredDynamicValue(npc, "Character", name),
$"{name}.Character");
List<(int SkillId, int Level)> actual = RequiredDynamicObjectList(character, "EnhanceSkillList", $"{name}.Character.EnhanceSkillList")
.Select(value => RequiredDynamicMap(value, $"{name} enhance skill"))
.Select(skill => (
SkillId: RequiredDynamicInteger(skill, "Id", $"{name} enhance skill"),
Level: RequiredDynamicInteger(skill, "Level", $"{name} enhance skill")))
.OrderBy(skill => skill.SkillId)
.ToList();
List<(int SkillId, int Level)> orderedExpected = expected.OrderBy(skill => skill.SkillId).ToList();
AssertEqual(orderedExpected.Count, actual.Count, $"{name} EnhanceSkillList count");
for (int i = 0; i < orderedExpected.Count; i++)
{
AssertEqual(orderedExpected[i].SkillId, actual[i].SkillId, $"{name} EnhanceSkillList[{i}].Id");
AssertEqual(orderedExpected[i].Level, actual[i].Level, $"{name} EnhanceSkillList[{i}].Level");
}
}

private static void AssertPreFightDoesNotDeployCharacter(
PreFightResponse preFightResponse,
long playerId,
Expand Down
2 changes: 1 addition & 1 deletion Resources/Configs/study_compatibility_4.6.0.json

Large diffs are not rendered by default.

Loading