Skip to content
Open
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
4 changes: 4 additions & 0 deletions AscNet.Common/Database/Player.cs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,10 @@ public void SaveChecked()
[BsonElement("unlock_comics")]
public List<int> UnlockComics { get; set; } = AscNet.Common.ArchiveDefaults.CreateDefaultUnlockedArchiveComics();

[BsonElement("archive_monster_kills")]
[BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)]
public Dictionary<int, int> ArchiveMonsterKills { get; set; } = new();

[BsonElement("life_tree_data")]
public NotifyLifeTreeData LifeTreeData { get; set; } = new();

Expand Down
14 changes: 13 additions & 1 deletion AscNet.Common/MsgPack/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3078,6 +3078,17 @@ public class PreFightRequest
[global::MessagePack.MessagePackObject(true)]
public class PreFightRequestPreFightData
{
[global::MessagePack.MessagePackObject(true)]
public class SimulateTrainInfoData
{
public Int32 BossId { get; set; }
public Int32 Period { get; set; }
public Int32 AtkLevel { get; set; }
public Int32 HpLevel { get; set; }
public Int32 Difficulty { get; set; }
public Int32 DangerCoefficient { get; set; }
}

public Int32 ChallengeCount { get; set; }
public UInt32 StageId { get; set; }
public Int32 ArenaSelectIndex { get; set; }
Expand All @@ -3095,6 +3106,7 @@ public class PreFightRequestPreFightData
public Int32 BossSingleStageType { get; set; }
public dynamic? BossSingleChallengeBuffGroup { get; set; }
public Int32? BossInshotTowerId { get; set; }
public SimulateTrainInfoData? SimulateTrainInfo { get; set; }
}

public PreFightRequestPreFightData PreFightData { get; set; }
Expand All @@ -3120,7 +3132,7 @@ public class PreFightResponseFightData
public Int32 FightCheckType { get; set; }
public Int32 SegmentFightCheckSecond { get; set; }
public Int32 StarsMark { get; set; }
public List<Int32> MonsterLevel { get; set; } = new();
public List<Int32>? MonsterLevel { get; set; } = new();
public List<dynamic> EventIds { get; set; } = new();
public dynamic? FightEventsWithLevel { get; set; }
public List<dynamic> NormalEventIds { get; set; } = new();
Expand Down
27 changes: 27 additions & 0 deletions AscNet.GameServer/Handlers/AccountModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using AscNet.Table.V2.share.fuben.bossinshot;
using AscNet.Table.V2.share.fuben.fashionstory;
using AscNet.Table.V2.share.fuben.transfinite;
using AscNet.Table.V2.share.fuben.simulatetrain;
using AscNet.Table.V2.share.miniactivity.dyemerge;
using AscNet.Table.V2.share.miniactivity.hitmouse;
using AscNet.Table.V2.share.theatre6;
Expand Down Expand Up @@ -1171,9 +1172,35 @@ private static NotifyArchiveLoginData BuildNotifyArchiveLoginData(Player player)
List<int> unlockComics = player.UnlockComics is { Count: > 0 }
? player.UnlockComics.Distinct().Order().ToList()
: ArchiveDefaults.CreateDefaultUnlockedArchiveComics();
List<SimulateTrainMonsterTable> simulateTrainBosses = TableReaderV2.Parse<SimulateTrainMonsterTable>()
.Where(row => row.NpcId.Count > 0)
.ToList();
// A zero-count record unlocks the archive's main entry without claiming a kill.
Dictionary<int, int> archiveMonsters = simulateTrainBosses
.Select(row => row.NpcId.First())
.Distinct()
.ToDictionary(npcId => npcId, _ => 0);
foreach ((int npcId, int killed) in player.ArchiveMonsterKills ?? [])
{
if (npcId > 0 && killed > 0)
archiveMonsters[npcId] = killed;
}

return new NotifyArchiveLoginData
{
Monsters = archiveMonsters
.OrderBy(record => record.Key)
.Select(record => new NotifyArchiveLoginData.NotifyArchiveLoginDataMonster
{
Id = checked((uint)record.Key),
Killed = record.Value
})
.ToList(),
MonsterUnlockIds = simulateTrainBosses
.Select(row => (uint)row.Id)
.Distinct()
.Order()
.ToList(),
UnlockComics = unlockComics
};
}
Expand Down
17 changes: 17 additions & 0 deletions AscNet.GameServer/Handlers/FightModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,14 @@ public static void PreFightRequestHandler(Session session, Packet.Request packet
return;
}

if (SimulateTrainModule.TryApplyPreFight(req.PreFightData, rsp.FightData, DateTimeOffset.UtcNow, out int simulateTrainCode)
&& simulateTrainCode != 0)
{
rsp.Code = simulateTrainCode;
session.SendResponse(rsp, packet.Id);
return;
}

if (TransfiniteModule.ApplyPreFight(session, req.PreFightData, out int transfiniteCode))
{
rsp.Code = transfiniteCode;
Expand Down Expand Up @@ -2512,6 +2520,10 @@ void AddRewardId(int? rewardId)
TaskModule.RecordArenaResult(session, arenaResult.Point);
}

NotifyArchiveMonsterRecord? simulateTrainArchiveRecord = SimulateTrainModule.RecordArchiveKill(
session.player,
session.fight?.PreFight.PreFightData,
req.Result);
bool updatedRepeatChallenge = RepeatChallengeModule.RecordStageClear(session.player, req.Result.StageId, challengeCount);
if (MainLineChapterIdsByStageId.Value.TryGetValue(responseStageId, out int mainLineChapterId))
{
Expand Down Expand Up @@ -2551,11 +2563,16 @@ void AddRewardId(int? rewardId)
MultiRewardGoodsList = multiRewards,
ChallengeCount = isQuickClear ? 0 : challengeCount,
ArenaResult = arenaResult,
SimulateTrainFightResult = SimulateTrainModule.BuildFightResult(
session.fight?.PreFight.PreFightData,
req.Result),
}
};

session.fight = null;
session.SendPush(new NotifyStageData() { StageList = new() { stageData } });
if (simulateTrainArchiveRecord is not null)
session.SendPush(simulateTrainArchiveRecord);
StudyProgressModule.SendTeachingStageUpdate(session, stageData);
bool sentTheatreProgress = BiancaTheatreModule.TrySendTheatreFightClearProgress(session, req.Result.StageId);
if (!sentTheatreProgress)
Expand Down
198 changes: 198 additions & 0 deletions AscNet.GameServer/Handlers/SimulateTrainModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using AscNet.Common.Database;
using AscNet.Common.MsgPack;
using AscNet.GameServer.Game;
using AscNet.Common.Util;
using AscNet.Table.V2.share.fuben.simulatetrain;
using MessagePack;

namespace AscNet.GameServer.Handlers;

[MessagePackObject(true)]
public sealed class SimulateTrainFightResultData
{
public int AtkLevel { get; set; }
public int HpLevel { get; set; }
public int Difficulty { get; set; }
public long FightTime { get; set; }
}

[MessagePackObject(true)]
public sealed class SimulateTrainNpcGroupData
{
public List<SimulateTrainNpcData> NpcList { get; set; } = [];
}

[MessagePackObject(true)]
public sealed class SimulateTrainNpcData
{
public int NpcId { get; set; }
public List<int> BufferIds { get; set; } = [];
public int Level { get; set; }
public List<object> MagicInfos { get; set; } = [];
public Dictionary<int, object> AttrTable { get; set; } = [];
}

internal static class SimulateTrainModule
{
private const int FightFramesPerSecond = 20;
private const int InvalidPreFightData = 1; // Retail code unobserved; any non-zero rejects the request.

private sealed record Data(
IReadOnlyDictionary<uint, SimulateTrainMonsterTable> MonstersByStage,
IReadOnlyDictionary<int, SimulateTrainAtkTable> AttackLevels,
IReadOnlyDictionary<int, SimulateTrainHpTable> HealthLevels,
IReadOnlyDictionary<(int BossId, int Period), int> PeriodBuffs);

private static readonly Lazy<Data> Runtime = new(Load);

public static bool IsStage(uint stageId) => Runtime.Value.MonstersByStage.ContainsKey(stageId);

public static bool TryApplyPreFight(
PreFightRequest.PreFightRequestPreFightData request,
PreFightResponse.PreFightResponseFightData fightData,
DateTimeOffset now,
out int code)
{
code = 0;
if (!Runtime.Value.MonstersByStage.TryGetValue(request.StageId, out SimulateTrainMonsterTable? monster))
return false;

PreFightRequest.PreFightRequestPreFightData.SimulateTrainInfoData? info = request.SimulateTrainInfo;
int difficultyIndex = (info?.Difficulty ?? 0) - 1;
if (info is null
|| info.BossId != monster.Id
|| difficultyIndex < 0
|| difficultyIndex >= monster.NpcId.Count
|| difficultyIndex >= monster.NpcLevel.Count
|| difficultyIndex >= monster.StageBuffId.Count
|| !Runtime.Value.AttackLevels.TryGetValue(info.AtkLevel, out SimulateTrainAtkTable? attack)
|| !Runtime.Value.HealthLevels.TryGetValue(info.HpLevel, out SimulateTrainHpTable? health))
{
code = InvalidPreFightData;
return true;
}

bool hasPeriodBuff = Runtime.Value.PeriodBuffs.TryGetValue(
(info.BossId, info.Period),
out int periodBuffId);
if (info.Period < 1 || (info.Period > 1 && !hasPeriodBuff))
{
code = InvalidPreFightData;
return true;
}

bool isImpasseDifficulty = monster.ImpasseTimeId > 0
&& difficultyIndex == monster.NpcId.Count - 1;
if (!IsTimeOpen(monster.TimeId, now)
|| (isImpasseDifficulty && !IsTimeOpen(monster.ImpasseTimeId, now)))
{
code = FashionStoryModule.StageLocked;
return true;
}

List<int> bufferIds = [monster.StageBuffId[difficultyIndex]];
if (hasPeriodBuff)
bufferIds.Add(periodBuffId);
bufferIds.Add(attack.AtkBuffId);
bufferIds.Add(health.HpBuffId);
bufferIds = bufferIds.Where(id => id > 0).Distinct().ToList();

fightData.FightCheckType = 1;
fightData.SegmentFightCheckSecond = 60;
fightData.MonsterLevel = null;
fightData.EventIds = [];
fightData.FightEventsWithLevel = new List<dynamic>();
fightData.NormalEventIds = [2];
fightData.NpcGroupList = new List<SimulateTrainNpcGroupData>
{
new()
{
NpcList =
[
new()
{
NpcId = monster.NpcId[difficultyIndex],
BufferIds = bufferIds,
Level = monster.NpcLevel[difficultyIndex],
}
]
}
};
fightData.Records = new Dictionary<string, dynamic>();
fightData.StageParams = new Dictionary<string, dynamic>();
fightData.Restartable = true;
return true;
}

public static SimulateTrainFightResultData? BuildFightResult(
PreFightRequest.PreFightRequestPreFightData? preFight,
FightSettleResult settle)
{
PreFightRequest.PreFightRequestPreFightData.SimulateTrainInfoData? info = preFight?.SimulateTrainInfo;
if (preFight is null || info is null || !IsStage(settle.StageId))
return null;

long activeFrames = Math.Max(0, settle.SettleFrame - settle.StartFrame - settle.PauseFrame);
return new SimulateTrainFightResultData
{
AtkLevel = info.AtkLevel,
HpLevel = info.HpLevel,
Difficulty = info.Difficulty,
FightTime = activeFrames / FightFramesPerSecond,
};
}

public static NotifyArchiveMonsterRecord? RecordArchiveKill(
Player player,
PreFightRequest.PreFightRequestPreFightData? preFight,
FightSettleResult settle)
{
PreFightRequest.PreFightRequestPreFightData.SimulateTrainInfoData? info = preFight?.SimulateTrainInfo;
if (!settle.IsWin
|| settle.IsForceExit
|| info is null
|| !Runtime.Value.MonstersByStage.TryGetValue(settle.StageId, out SimulateTrainMonsterTable? monster))
{
return null;
}

int difficultyIndex = info.Difficulty - 1;
if (difficultyIndex < 0 || difficultyIndex >= monster.NpcId.Count)
return null;

int npcId = monster.NpcId[difficultyIndex];
player.ArchiveMonsterKills ??= [];
int killed = player.ArchiveMonsterKills.TryGetValue(npcId, out int previous)
? checked(previous + 1)
: 1;
player.ArchiveMonsterKills[npcId] = killed;
return new NotifyArchiveMonsterRecord
{
Monsters =
[
new()
{
Id = checked((uint)npcId),
Killed = checked((uint)killed),
}
]
};
}

private static bool IsTimeOpen(int timeId, DateTimeOffset now) =>
timeId == 0 || ActivityScheduleService.IsOpen(timeId, now);

private static Data Load()
{
Dictionary<uint, SimulateTrainMonsterTable> monsters = TableReaderV2.Parse<SimulateTrainMonsterTable>()
.ToDictionary(monster => checked((uint)monster.StageId));
Dictionary<int, SimulateTrainAtkTable> attackLevels = TableReaderV2.Parse<SimulateTrainAtkTable>()
.ToDictionary(level => level.AtkLevel);
Dictionary<int, SimulateTrainHpTable> healthLevels = TableReaderV2.Parse<SimulateTrainHpTable>()
.ToDictionary(level => level.HpLevel);
Dictionary<(int BossId, int Period), int> periodBuffs = TableReaderV2.Parse<SimulateTrainPeriodBuffTable>()
.ToDictionary(row => (row.BossId, row.Period), row => row.BuffId);

return new Data(monsters, attackLevels, healthLevels, periodBuffs);
}
}
Loading