diff --git a/Common/Data/Excel/CardExcel.cs b/Common/Data/Excel/CardExcel.cs index 5ddbf79..83a5b05 100644 --- a/Common/Data/Excel/CardExcel.cs +++ b/Common/Data/Excel/CardExcel.cs @@ -19,6 +19,7 @@ public class CardExcel : ExcelResource [JsonProperty("profile")] public List> Profile { get; set; } = []; public List> Pieces { get; set; } = []; public List Attribute { get; set; } = []; + [JsonProperty("SpineID")] public uint SpineId { get; set; } public override uint GetId() { return Icon; diff --git a/Common/Data/Excel/NodeConditionExcel.cs b/Common/Data/Excel/NodeConditionExcel.cs new file mode 100644 index 0000000..419ce45 --- /dev/null +++ b/Common/Data/Excel/NodeConditionExcel.cs @@ -0,0 +1,41 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +// nodecondition.json: NodeConditionId → NodeXCost per sub-node (1-9) +[ResourceEntity("nodecondition.json")] +public class NodeConditionExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + + [JsonProperty("Node1Cost")] public List> Node1Cost { get; set; } = []; + [JsonProperty("Node2Cost")] public List> Node2Cost { get; set; } = []; + [JsonProperty("Node3Cost")] public List> Node3Cost { get; set; } = []; + [JsonProperty("Node4Cost")] public List> Node4Cost { get; set; } = []; + [JsonProperty("Node5Cost")] public List> Node5Cost { get; set; } = []; + [JsonProperty("Node6Cost")] public List> Node6Cost { get; set; } = []; + [JsonProperty("Node7Cost")] public List> Node7Cost { get; set; } = []; + [JsonProperty("Node8Cost")] public List> Node8Cost { get; set; } = []; + [JsonProperty("Node9Cost")] public List> Node9Cost { get; set; } = []; + + public List> GetNodeCost(int subIdx) => subIdx switch + { + 1 => Node1Cost, + 2 => Node2Cost, + 3 => Node3Cost, + 4 => Node4Cost, + 5 => Node5Cost, + 6 => Node6Cost, + 7 => Node7Cost, + 8 => Node8Cost, + 9 => Node9Cost, + _ => [] + }; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.NodeConditionData[Id] = this; + } +} diff --git a/Common/Data/Excel/SpineExcel.cs b/Common/Data/Excel/SpineExcel.cs new file mode 100644 index 0000000..7737d56 --- /dev/null +++ b/Common/Data/Excel/SpineExcel.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +// spine.json: SpineId → Node{i}Req (nodecondition ID per master node index) +[ResourceEntity("spine.json")] +public class SpineExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + + [JsonProperty("Node1Req")] public uint Node1Req { get; set; } + [JsonProperty("Node2Req")] public uint Node2Req { get; set; } + [JsonProperty("Node3Req")] public uint Node3Req { get; set; } + [JsonProperty("Node4Req")] public uint Node4Req { get; set; } + [JsonProperty("Node5Req")] public uint Node5Req { get; set; } + [JsonProperty("Node6Req")] public uint Node6Req { get; set; } + + public uint GetNodeReq(int mastIdx) => mastIdx switch + { + 1 => Node1Req, + 2 => Node2Req, + 3 => Node3Req, + 4 => Node4Req, + 5 => Node5Req, + 6 => Node6Req, + _ => 0 + }; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.SpineData[Id] = this; + } +} diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index c21fe8f..9db7771 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -16,6 +16,8 @@ public static class GameData public static Dictionary ArItemData { get; private set; } = []; public static Dictionary ManifestationData { get; private set; } = []; public static Dictionary Rogue3DDifficultData { get; private set; } = []; + public static Dictionary SpineData { get; private set; } = []; + public static Dictionary NodeConditionData { get; private set; } = []; } public static class GameResourceTemplateId diff --git a/Common/Database/Player/PlayerGameData.cs b/Common/Database/Player/PlayerGameData.cs index b8f3f56..d7c5e59 100644 --- a/Common/Database/Player/PlayerGameData.cs +++ b/Common/Database/Player/PlayerGameData.cs @@ -16,6 +16,7 @@ public class PlayerGameData : BaseDatabaseDataHelper public Sex Gender { get; set; } = Sex.Female; public uint Vigor { get; set; } = 240; [SugarColumn(IsJson = true)] public List Attrs { get; set; } = []; + [SugarColumn(IsJson = true)] public List StrAttrs { get; set; } = []; [SugarColumn(IsJson = true)] public List ShowItems { get; set; } = []; public static PlayerGameData? GetPlayerByUid(long uid) @@ -45,4 +46,11 @@ public class PlayerAttr public uint Gid { get; set; } public uint Sid { get; set; } public uint Val { get; set; } +} + +public class PlayerStrAttr +{ + public uint Gid { get; set; } + public uint Sid { get; set; } + public string Val { get; set; } = ""; } \ No newline at end of file diff --git a/GameServer/Server/CallGS/Handlers/Girl/GirlSpine_ChildUnLock.cs b/GameServer/Server/CallGS/Handlers/Girl/GirlSpine_ChildUnLock.cs new file mode 100644 index 0000000..d2519a1 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Girl/GirlSpine_ChildUnLock.cs @@ -0,0 +1,182 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Girl; + +// Spine node encoding: (MastIdx << 8) | SubIdx stored as uint in CharacterInfo.Spines +// GetStrAttribute(Gid=30, Sid=Detail) stores JSON: { "": { "ns": , "tbn": [0,0], "tbr": [] } } +[CallGSApi("GirlSpine_ChildUnLock")] +public class GirlSpine_ChildUnLock : ICallGSHandler +{ + private const uint SpineStrAttrGid = 30; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.CardId == 0 || req.Info == null || req.Materials == null) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var card = player.CharacterManager.GetCharacterByGUID((uint)req.CardId); + if (card == null) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var mastIdx = req.Info.Indx; + var subIdx = req.Info.InSubIdx; + if (mastIdx <= 0 || subIdx <= 0) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + // Spines[MastIdx-1] is a bitmask; bit (SubIdx-1) = 1 means that sub-node is unlocked. + // GetSpine(MastIdx, SubIdx) checks (Spines[MastIdx-1] & (1 << (SubIdx-1))) != 0 + int spineListIdx = mastIdx - 1; + uint spineBit = 1u << (subIdx - 1); + + while (card.Spines.Count <= spineListIdx) + card.Spines.Add(0); + + if ((card.Spines[spineListIdx] & spineBit) != 0) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"tip.girlcard_alread_break\"}"); + return; + } + + // Consume materials + var requestedMaterials = new Dictionary(); + foreach (var row in req.Materials) + { + if (row == null || row.Count < 5) continue; + var genre = (uint)Math.Max(0, row[0]); + var detail = (uint)Math.Max(0, row[1]); + var particular = (uint)Math.Max(0, row[2]); + var level = (uint)Math.Max(0, row[3]); + var count = (uint)Math.Max(0, row[4]); + if (genre == 0 || detail == 0 || particular == 0 || level == 0 || count == 0) continue; + var tid = GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + requestedMaterials[tid] = requestedMaterials.GetValueOrDefault(tid) + count; + } + + var syncItems = new List(); + foreach (var (tid, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.FirstOrDefault(x => x.TemplateId == tid); + if (item == null || item.ItemCount < count) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"tip.not_material\"}"); + return; + } + } + + foreach (var (tid, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.First(x => x.TemplateId == tid); + item.ItemCount -= count; + var proto = item.ToProto(); + if (item.ItemCount == 0) + { + player.InventoryManager.InventoryData.Items.Remove(item.UniqueId); + proto.Count = 0; + } + syncItems.Add(proto); + } + + // Unlock the spine node by setting the corresponding bit + card.Spines[spineListIdx] |= spineBit; + syncItems.Add(card.ToProto()); + + // Extract Detail and Particular from TemplateId for StrAttr + var cardDetail = (uint)((card.TemplateId >> 16) & 0xFFFF); + var cardParticular = (uint)((card.TemplateId >> 32) & 0xFFFF); + + // Build and persist StrAttr JSON: { "": { "ns": mastIdx, "tbn": [0,0], "tbr": [] } } + UpdateSpineStrAttr(player.Data, cardDetail, cardParticular, mastIdx); + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + DatabaseHelper.SaveDatabaseType(player.Data); + + // Send NtfSetStrAttr so client's GetStrAttribute(30, Detail) returns fresh data + var strAttrData = GetSpineStrAttrJson(player.Data, cardDetail); + var ntfStrAttr = new NtfSetStrAttr { Gid = SpineStrAttrGid, Sid = cardDetail, Val = strAttrData }; + await connection.Player!.SendPacket(CmdIds.NtfSetStrAttr, ntfStrAttr); + + var sync = new NtfSyncPlayer(); + sync.Items.AddRange(syncItems); + + var rsp = $"{{\"tb\":{{\"D\":{cardDetail},\"pId\":{req.CardId},\"MastIdx\":{mastIdx},\"SubIdx\":{subIdx}}}}}"; + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", rsp, sync); + } + + private static void UpdateSpineStrAttr(PlayerGameData data, uint detail, uint particular, int mastIdx) + { + var existing = data.StrAttrs.FirstOrDefault(x => x.Gid == SpineStrAttrGid && x.Sid == detail); + if (existing == null) + { + existing = new PlayerStrAttr { Gid = SpineStrAttrGid, Sid = detail, Val = "{}" }; + data.StrAttrs.Add(existing); + } + + var root = JsonSerializer.Deserialize>(existing.Val) + ?? new Dictionary(); + + var key = particular.ToString(); + if (!root.TryGetValue(key, out var entry)) + entry = new SpineStrData(); + + entry.Ns = mastIdx; + root[key] = entry; + + existing.Val = JsonSerializer.Serialize(root); + } + + private static string GetSpineStrAttrJson(PlayerGameData data, uint detail) + { + var existing = data.StrAttrs.FirstOrDefault(x => x.Gid == SpineStrAttrGid && x.Sid == detail); + return existing?.Val ?? "{}"; + } +} + +internal sealed class ChildUnLockParam +{ + [JsonPropertyName("pId")] + public int CardId { get; set; } + + [JsonPropertyName("tbInfo")] + public NodeInfo? Info { get; set; } + + [JsonPropertyName("tbMat")] + public List> Materials { get; set; } = []; +} + +internal sealed class NodeInfo +{ + [JsonPropertyName("Indx")] + public int Indx { get; set; } + + [JsonPropertyName("InSubIdx")] + public int InSubIdx { get; set; } +} + +internal sealed class SpineStrData +{ + [JsonPropertyName("ns")] + public int Ns { get; set; } = 0; + + [JsonPropertyName("tbn")] + public List Tbn { get; set; } = [0, 0]; + + [JsonPropertyName("tbr")] + public List Tbr { get; set; } = []; +} diff --git a/GameServer/Server/CallGS/Handlers/Girl/GirlSpine_UnlockNodeOneKey.cs b/GameServer/Server/CallGS/Handlers/Girl/GirlSpine_UnlockNodeOneKey.cs new file mode 100644 index 0000000..a1f3a2a --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Girl/GirlSpine_UnlockNodeOneKey.cs @@ -0,0 +1,125 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Girl; + +[CallGSApi("GirlSpine_UnlockNodeOneKey")] +public class GirlSpine_UnlockNodeOneKey : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.CardId == 0 || req.MastIdx <= 0 || req.SubIdxList == null || req.SubIdxList.Count == 0) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var card = player.CharacterManager.GetCharacterByGUID((uint)req.CardId); + if (card == null) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + // Look up costs from config: CardExcel → SpineId → SpineExcel → NodeConditionId → NodeConditionExcel + var cardTemplateId = card.TemplateId; + var cardDetail = (uint)((cardTemplateId >> 16) & 0xFFFF); + var cardParticular = (uint)((cardTemplateId >> 32) & 0xFFFF); + + var cardExcel = GameData.CardData.Values.FirstOrDefault( + x => x.Detail == cardDetail && x.Particular == cardParticular); + + var requestedMaterials = new Dictionary(); + + if (cardExcel != null && GameData.SpineData.TryGetValue(cardExcel.SpineId, out var spineExcel)) + { + var nodeCondId = spineExcel.GetNodeReq(req.MastIdx); + if (nodeCondId != 0 && GameData.NodeConditionData.TryGetValue(nodeCondId, out var nodeCond)) + { + int spineListIdx = req.MastIdx - 1; + while (card.Spines.Count <= spineListIdx) card.Spines.Add(0); + var currentMask = card.Spines[spineListIdx]; + + foreach (var subIdx in req.SubIdxList) + { + if (subIdx <= 0) continue; + uint bit = 1u << (subIdx - 1); + if ((currentMask & bit) != 0) continue; // already unlocked, skip cost + + foreach (var row in nodeCond.GetNodeCost(subIdx)) + { + if (row.Count < 5) continue; + var tid = GameResourceTemplateId.FromGdpl( + (uint)row[0], (uint)row[1], (uint)row[2], (uint)row[3]); + requestedMaterials[tid] = requestedMaterials.GetValueOrDefault(tid) + (uint)row[4]; + } + } + } + } + + // Validate materials + foreach (var (tid, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.FirstOrDefault(x => x.TemplateId == tid); + if (item == null || item.ItemCount < count) + { + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", "{\"sErr\":\"tip.not_material\"}"); + return; + } + } + + // Consume materials + var syncItems = new List(); + foreach (var (tid, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.First(x => x.TemplateId == tid); + item.ItemCount -= count; + var proto = item.ToProto(); + if (item.ItemCount == 0) + { + player.InventoryManager.InventoryData.Items.Remove(item.UniqueId); + proto.Count = 0; + } + syncItems.Add(proto); + } + + // Unlock all specified sub-nodes + int mastSpineIdx = req.MastIdx - 1; + while (card.Spines.Count <= mastSpineIdx) card.Spines.Add(0); + foreach (var subIdx in req.SubIdxList) + { + if (subIdx <= 0) continue; + card.Spines[mastSpineIdx] |= 1u << (subIdx - 1); + } + syncItems.Add(card.ToProto()); + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var sync = new NtfSyncPlayer(); + sync.Items.AddRange(syncItems); + + // No s2c handler exists for GirlSpine_UnlockNodeOneKey — reuse GirlSpine_ChildUnLock + // which calls UI.CloseConnection() and triggers OnNerveNodeUp to refresh the UI. + var lastSubIdx = req.SubIdxList.Count > 0 ? req.SubIdxList[^1] : 9; + var rsp = $"{{\"tb\":{{\"D\":{cardDetail},\"pId\":{req.CardId},\"MastIdx\":{req.MastIdx},\"SubIdx\":{lastSubIdx}}}}}"; + await CallGSRouter.SendScript(connection, "GirlSpine_ChildUnLock", rsp, sync); + } +} + +internal sealed class OneKeyUnlockParam +{ + [JsonPropertyName("pId")] + public int CardId { get; set; } + + [JsonPropertyName("nIdx")] + public int MastIdx { get; set; } + + [JsonPropertyName("tbOneKey")] + public List SubIdxList { get; set; } = []; +}