package gameserver import ( "fmt" "math/rand" "sync" "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" ) // CURRENT_BATTLE mirrors battle.rs CURRENT_BATTLE (token -> SceneBattleInfo). var CURRENT_BATTLE syncMap type syncMap struct { m map[uint32]*goproto.SceneBattleInfo } func (sm *syncMap) init() { if sm.m == nil { sm.m = map[uint32]*goproto.SceneBattleInfo{} } } func (sm *syncMap) Store(k uint32, v *goproto.SceneBattleInfo) { muBattle.Lock() sm.init() sm.m[k] = v muBattle.Unlock() } func (sm *syncMap) Load(k uint32) (*goproto.SceneBattleInfo, bool) { muBattle.Lock() defer muBattle.Unlock() sm.init() v, ok := sm.m[k] return v, ok } func (sm *syncMap) Delete(k uint32) { muBattle.Lock(); sm.init(); delete(sm.m, k); muBattle.Unlock() } func randUint32() uint32 { return rand.Uint32() } func emptyRelicPresetPlans(avatarID uint32) *goproto.RelicPresetGetPlanScRsp { return &goproto.RelicPresetGetPlanScRsp{AvatarId: avatarID} } func emptyRelicSmartWearPlans(avatarID uint32) *goproto.RelicSmartWearGetPlanScRsp { return &goproto.RelicSmartWearGetPlanScRsp{AvatarId: avatarID} } func emptyPinnedRelics(avatarID uint32) *goproto.RelicSmartWearGetPinRelicScRsp { return &goproto.RelicSmartWearGetPinRelicScRsp{AvatarId: avatarID} } func emptyEquipmentRecommendation(avatarID uint32, kind goproto.BigDataRecommendType) goproto.GetBigDataRecommendScRsp { return goproto.GetBigDataRecommendScRsp{EquipAvatar: avatarID, BigDataRecommendType: kind} } func playerArchive(s *Session) *goproto.ArchiveData { archive := &goproto.ArchiveData{} if player := s.JSON(); player != nil { for id := range player.Avatars { archive.ArchiveAvatarIdList = append(archive.ArchiveAvatarIdList, id) } sortU32Slice(archive.ArchiveAvatarIdList) seen := map[uint32]bool{} for _, equipment := range player.Lightcones { if !seen[equipment.ItemID] { seen[equipment.ItemID] = true archive.ArchiveEquipmentIdList = append(archive.ArchiveEquipmentIdList, equipment.ItemID) } } sortU32Slice(archive.ArchiveEquipmentIdList) } return archive } func init() { // AdventureModule._OnGetCurBattleInfoScRsp dereferences BattleInfo even // when nothing is running (Player.log 2026-10-24 NRE on an empty reply); // the isolated reference answers with an empty, non-null SceneBattleInfo. handle("GetCurBattleInfo", func(s *Session, req *goproto.GetCurBattleInfoCsReq, rsp *goproto.GetCurBattleInfoScRsp) error { rsp.BattleInfo = &goproto.SceneBattleInfo{} return nil }) handle("GetBag", func(s *Session, req *goproto.GetBagCsReq, rsp *goproto.GetBagScRsp) error { player := s.JSON() if player == nil { return nil } if err := ensureCatalogLightcones(player); err != nil { return err } if err := ensureCatalogRelics(player); err != nil { return err } for i := range player.Lightcones { rsp.EquipmentList = append(rsp.EquipmentList, player.Lightcones[i].ToProtoEquipment()) } relics, omitted, err := supportedProtoRelics(player.Relics) if err != nil { return err } if omitted > 0 { Log(fmt.Sprintf("[INVENTORY] omitted %d Relic entries without a Client RelicConfig row", omitted)) } rsp.RelicList = append(rsp.RelicList, relics...) materials, err := bagMaterials(player) if err != nil { return err } rsp.MaterialList = materials // Avatar IDs do not imply item IDs. The old 130000+avatar formula // produced nonexistent materials and broke the client's inventory init. return nil }) handle("GetArchiveData", func(s *Session, req *goproto.GetArchiveDataCsReq, rsp *goproto.GetArchiveDataScRsp) error { rsp.Retcode = 0 rsp.ArchiveData = playerArchive(s) return nil }) handle("GetUpdatedArchiveData", func(s *Session, req *goproto.GetUpdatedArchiveDataCsReq, rsp *goproto.GetUpdatedArchiveDataScRsp) error { rsp.ArchiveData = playerArchive(s) return nil }) // These requests are made while the Client builds equipment/relic // selection panels. An empty raw response is not equivalent to a valid // empty list: the Client uses the echoed AvatarId to complete the request. handle("RelicPresetGetPlan", func(_ *Session, req *goproto.RelicPresetGetPlanCsReq, rsp *goproto.RelicPresetGetPlanScRsp) error { *rsp = *emptyRelicPresetPlans(req.AvatarId) return nil }) handle("RelicSmartWearGetPlan", func(_ *Session, req *goproto.RelicSmartWearGetPlanCsReq, rsp *goproto.RelicSmartWearGetPlanScRsp) error { *rsp = *emptyRelicSmartWearPlans(req.AvatarId) return nil }) handle("RelicSmartWearGetPinRelic", func(_ *Session, req *goproto.RelicSmartWearGetPinRelicCsReq, rsp *goproto.RelicSmartWearGetPinRelicScRsp) error { *rsp = *emptyPinnedRelics(req.AvatarId) return nil }) handle("GetBigDataRecommend", func(_ *Session, req *goproto.GetBigDataRecommendCsReq, rsp *goproto.GetBigDataRecommendScRsp) error { *rsp = emptyEquipmentRecommendation(req.EquipAvatar, req.BigDataRecommendType) return nil }) // --- lightcone equipping (Rust: set_lightcone_equipper) --- handle("DressAvatar", func(s *Session, req *goproto.DressAvatarCsReq, rsp *goproto.DressAvatarScRsp) error { pkt, err := setLightconeEquipper(s, req.AvatarId, req.EquipmentUniqueId) if err != nil { return err } if pkt != nil { return s.Send(pkt) } return nil }) handle("TakeOffEquipment", func(s *Session, req *goproto.TakeOffEquipmentCsReq, rsp *goproto.TakeOffEquipmentScRsp) error { pkt, err := setLightconeEquipper(s, req.AvatarId, 0) if err != nil { return err } if pkt != nil { return s.Send(pkt) } return nil }) handle("DressRelicAvatar", func(s *Session, req *goproto.DressRelicAvatarCsReq, rsp *goproto.DressRelicAvatarScRsp) error { rsp.AvatarId = req.AvatarId pkt, err := equipRelic(s, req) if err != nil { return err } if pkt != nil { return s.Send(pkt) } return nil }) handle("TakeOffRelic", func(s *Session, req *goproto.TakeOffRelicCsReq, rsp *goproto.TakeOffRelicScRsp) error { pkt, err := unequipRelic(s, req) if err != nil { return err } if pkt != nil { return s.Send(pkt) } return nil }) handle("RankUpAvatar", func(s *Session, req *goproto.RankUpAvatarCsReq, rsp *goproto.RankUpAvatarScRsp) error { player := s.JSON() if player == nil { return nil } avatar, ok := player.Avatars[req.AvatarId] if !ok { return nil } res, err := common.GameRes() if err != nil { return err } maximum := res.AvatarConfigs[req.AvatarId].MaxRank if maximum == 0 || req.Rank > maximum { rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_REACH_MAX) return nil } if req.Rank != avatar.Data.Rank+1 { rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_NOT_MATCH) return nil } if _, active := CURRENT_BATTLE.Load(s.Token); active { rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_NOT_MATCH) return nil } // Ordinary Eidolons do not implicitly unlock EX, even at the final rank. materials, err := eidolonMaterials(player) if err != nil { return err } if err := common.SaveAvatarRank(req.AvatarId, req.Rank); err != nil { return err } avatar.Data.Rank = req.Rank materials, err = eidolonMaterials(player) if err != nil { return err } ret := &goproto.PlayerSyncScNotify{} buildSync(player, ret, []uint32{avatar.AvatarID}, nil, nil) ret.MaterialList = materials return s.Send(ret) }) handle("GetBigDataAllRecommend", func(s *Session, req *goproto.GetBigDataAllRecommendCsReq, rsp *goproto.GetBigDataAllRecommendScRsp) error { rsp.BigDataRecommendType = req.BigDataRecommendType res, err := common.GameRes() if err != nil { return nil } switch req.BigDataRecommendType { case goproto.BigDataRecommendType_BIG_DATA_RECOMMEND_TYPE_RELIC_AVATAR: inner := &goproto.BigDataRecommendRelicAvatar{} setIDs := sortedU32Keys2(res.RelicAvatarRecommend) for _, setID := range setIDs { list := res.RelicAvatarRecommend[setID] first := uint32(0) if len(list) > 0 { first = list[0] } inner.RecommendedAvatarInfoList = append(inner.RecommendedAvatarInfoList, &goproto.RecomendedAvatarInfo{ AvatarIdList: list, RecommendAvatarId: first, RelicSetId: setID, }) } rsp.RecommendType = &goproto.GetBigDataAllRecommendScRsp_RelicAvatar{RelicAvatar: inner} case goproto.BigDataRecommendType_BIG_DATA_RECOMMEND_TYPE_AVATAR_RELIC: // No AvatarRelicRecommend rows are available in the loaded resource. // Empty placeholder rows crash the Client while it reads sub-properties. inner := &goproto.BigDataRecommendAvatarRelic{} rsp.RecommendType = &goproto.GetBigDataAllRecommendScRsp_AvatarRelic{AvatarRelic: inner} } return nil }) // --- mail --- handle("GetMail", func(s *Session, req *goproto.GetMailCsReq, rsp *goproto.GetMailScRsp) error { rsp.IsEnd = true rsp.MailList = append(rsp.MailList, &goproto.ClientMail{ Title: "Welcome!", Sender: "Server", Content: "Welcome!", Id: 1, Time: 1716041089, ExpireTime: 1718633089, Attachment: &goproto.ItemList{ ItemList: []*goproto.Item{{ItemId: 1310, Level: 80, Num: 1}}, }, }) return nil }) // --- chat/friends (empty-but-valid) --- handle("GetPrivateChatHistory", func(s *Session, req *goproto.GetPrivateChatHistoryCsReq, rsp *goproto.GetPrivateChatHistoryScRsp) error { rsp.Retcode = 0 return nil }) handle("GetFriendListInfo", func(s *Session, req *goproto.GetFriendListInfoCsReq, rsp *goproto.GetFriendListInfoScRsp) error { rsp.Retcode = 0 return nil }) handle("GetFriendLoginInfo", func(s *Session, req *goproto.GetFriendLoginInfoCsReq, rsp *goproto.GetFriendLoginInfoScRsp) error { rsp.Retcode = 0 return nil }) } // --------------------------------------------------------------------------- // equip helpers (Go port of inventory.rs helper fns) // --------------------------------------------------------------------------- func setLightconeEquipper(s *Session, targetAvatarID, targetLCUID uint32) (*goproto.PlayerSyncScNotify, error) { player := s.JSON() if player == nil { return nil, nil } targetAvatar, ok := player.Avatars[targetAvatarID] if !ok { return nil, nil } ret := &goproto.PlayerSyncScNotify{} curAvatarLCIdx := -1 targetLCIdx := -1 for i := range player.Lightcones { if player.Lightcones[i].EquipAvatar == targetAvatar.AvatarID { curAvatarLCIdx = i } if player.Lightcones[i].UniqueID() == targetLCUID { targetLCIdx = i } } // undress if targetLCUID == 0 { if curAvatarLCIdx >= 0 { lc := &player.Lightcones[curAvatarLCIdx] previousOwner := lc.EquipAvatar lc.EquipAvatar = 0 if err := common.SaveEquipmentAssignments(map[uint32]uint32{lc.InternalUID: 0}, nil); err != nil { lc.EquipAvatar = previousOwner return nil, err } buildSync(player, ret, []uint32{targetAvatar.AvatarID}, []int{curAvatarLCIdx}, nil) return ret, nil } return nil, nil } if targetLCIdx < 0 { return nil, nil } if targetLCIdx == curAvatarLCIdx { return nil, nil } var avatarsSync []uint32 var lcIdxSync []int previousOwners := make(map[uint32]uint32, 2) remember := func(idx int) { lc := &player.Lightcones[idx] previousOwners[lc.InternalUID] = lc.EquipAvatar } // if the current avatar holds an LC, swap owners with the target LC's owner if curAvatarLCIdx >= 0 { remember(curAvatarLCIdx) remember(targetLCIdx) player.Lightcones[curAvatarLCIdx].EquipAvatar = player.Lightcones[targetLCIdx].EquipAvatar } avatarsSync = append(avatarsSync, player.Lightcones[targetLCIdx].EquipAvatar, targetAvatar.AvatarID) player.Lightcones[targetLCIdx].EquipAvatar = targetAvatar.AvatarID if curAvatarLCIdx < 0 { remember(targetLCIdx) } owners := make(map[uint32]uint32, len(previousOwners)) for i := range player.Lightcones { if _, changed := previousOwners[player.Lightcones[i].InternalUID]; changed { owners[player.Lightcones[i].InternalUID] = player.Lightcones[i].EquipAvatar } } if err := common.SaveEquipmentAssignments(owners, nil); err != nil { for i := range player.Lightcones { if owner, changed := previousOwners[player.Lightcones[i].InternalUID]; changed { player.Lightcones[i].EquipAvatar = owner } } return nil, err } if curAvatarLCIdx >= 0 { lcIdxSync = append(lcIdxSync, curAvatarLCIdx) } lcIdxSync = append(lcIdxSync, targetLCIdx) buildSync(player, ret, avatarsSync, lcIdxSync, nil) return ret, nil } func equipRelic(s *Session, req *goproto.DressRelicAvatarCsReq) (*goproto.PlayerSyncScNotify, error) { player := s.JSON() if player == nil { return nil, nil } supportedRelics, err := supportedRelicIDs() if err != nil { return nil, err } targetAvatar, ok := player.Avatars[req.AvatarId] if !ok { return nil, nil } ret := &goproto.PlayerSyncScNotify{} var avatarIDsToSync []uint32 var relicIdxToSync []int previousOwners := make(map[uint32]uint32) for _, param := range req.SwitchList { targetRelicIdx := -1 for i := range player.Relics { if player.Relics[i].UniqueID() == param.RelicUniqueId { targetRelicIdx = i break } } if targetRelicIdx < 0 { continue } if _, supported := supportedRelics[player.Relics[targetRelicIdx].RelicID]; !supported { continue } // The Client supplies both the desired slot and the inventory UID. // Ignore malformed/stale requests rather than equipping a Relic into a // different slot than the one represented by its configured item ID. if player.Relics[targetRelicIdx].Slot() != param.RelicType { continue } curAvatarRelicIdx := -1 for i := range player.Relics { r := &player.Relics[i] if r.EquipAvatar == targetAvatar.AvatarID && r.Slot() == param.RelicType { curAvatarRelicIdx = i break } } if curAvatarRelicIdx >= 0 { previousOwners[player.Relics[curAvatarRelicIdx].InternalUID] = player.Relics[curAvatarRelicIdx].EquipAvatar previousOwners[player.Relics[targetRelicIdx].InternalUID] = player.Relics[targetRelicIdx].EquipAvatar avatarIDsToSync = append(avatarIDsToSync, player.Relics[curAvatarRelicIdx].EquipAvatar) player.Relics[curAvatarRelicIdx].EquipAvatar = player.Relics[targetRelicIdx].EquipAvatar relicIdxToSync = append(relicIdxToSync, curAvatarRelicIdx) } avatarIDsToSync = append(avatarIDsToSync, player.Relics[targetRelicIdx].EquipAvatar) if _, remembered := previousOwners[player.Relics[targetRelicIdx].InternalUID]; !remembered { previousOwners[player.Relics[targetRelicIdx].InternalUID] = player.Relics[targetRelicIdx].EquipAvatar } player.Relics[targetRelicIdx].EquipAvatar = targetAvatar.AvatarID avatarIDsToSync = append(avatarIDsToSync, player.Relics[targetRelicIdx].EquipAvatar) relicIdxToSync = append(relicIdxToSync, targetRelicIdx) } if len(relicIdxToSync) == 0 { return nil, nil } owners := make(map[uint32]uint32, len(previousOwners)) for i := range player.Relics { if _, changed := previousOwners[player.Relics[i].InternalUID]; changed { owners[player.Relics[i].InternalUID] = player.Relics[i].EquipAvatar } } if err := common.SaveEquipmentAssignments(nil, owners); err != nil { for i := range player.Relics { if owner, changed := previousOwners[player.Relics[i].InternalUID]; changed { player.Relics[i].EquipAvatar = owner } } return nil, err } buildSync(player, ret, avatarIDsToSync, nil, relicIdxToSync) return ret, nil } func unequipRelic(s *Session, req *goproto.TakeOffRelicCsReq) (*goproto.PlayerSyncScNotify, error) { player := s.JSON() if player == nil { return nil, nil } if _, ok := player.Avatars[req.AvatarId]; !ok { return nil, nil } ret := &goproto.PlayerSyncScNotify{} var relicIdxToSync []int previousOwners := make(map[uint32]uint32) for _, slot := range req.RelicTypeList { for i := range player.Relics { r := &player.Relics[i] if r.EquipAvatar == req.AvatarId && r.Slot() == slot { previousOwners[r.InternalUID] = r.EquipAvatar r.EquipAvatar = 0 relicIdxToSync = append(relicIdxToSync, i) } } } if len(relicIdxToSync) == 0 { return nil, nil } owners := make(map[uint32]uint32, len(previousOwners)) for i := range player.Relics { if _, changed := previousOwners[player.Relics[i].InternalUID]; changed { owners[player.Relics[i].InternalUID] = player.Relics[i].EquipAvatar } } if err := common.SaveEquipmentAssignments(nil, owners); err != nil { for i := range player.Relics { if owner, changed := previousOwners[player.Relics[i].InternalUID]; changed { player.Relics[i].EquipAvatar = owner } } return nil, err } buildSync(player, ret, []uint32{req.AvatarId}, nil, relicIdxToSync) return ret, nil } func buildSync( player *common.FreesrData, ret *goproto.PlayerSyncScNotify, avatarIDs []uint32, lightconeIndexes []int, relicIndexes []int, ) { supportedRelics, supportedErr := supportedRelicIDs() if supportedErr != nil { Log("[INVENTORY] cannot resolve Client RelicConfig; omitting Relic sync: " + supportedErr.Error()) supportedRelics = map[uint32]struct{}{} } sync := &goproto.AvatarSync{} seenAvatars := make(map[uint32]bool, len(avatarIDs)) for _, id := range avatarIDs { if seenAvatars[id] { continue } seenAvatars[id] = true if av, ok := player.Avatars[id]; ok { sync.AvatarList = append(sync.AvatarList, av.ToAvatarProto(player.FindLightconeFor(id), player.MainCharacter.ID(), player.MarchType.ID())) sync.AvatarPathDataInfoList = append(sync.AvatarPathDataInfoList, av.ToAvatarPathDataProto(player.FindLightconeFor(id), filterSupportedRelics(player.FindRelicsFor(id), supportedRelics), player.DressedSkins[id])) } } ret.AvatarSync = sync seenRelics := make(map[int]bool, len(relicIndexes)) for _, idx := range relicIndexes { if seenRelics[idx] { continue } seenRelics[idx] = true if idx >= 0 && idx < len(player.Relics) { if _, supported := supportedRelics[player.Relics[idx].RelicID]; !supported { continue } ret.RelicList = append(ret.RelicList, player.Relics[idx].ToProtoRelic()) } } seenLightcones := make(map[int]bool, len(lightconeIndexes)) for _, idx := range lightconeIndexes { if seenLightcones[idx] { continue } seenLightcones[idx] = true if idx >= 0 && idx < len(player.Lightcones) { ret.EquipmentList = append(ret.EquipmentList, player.Lightcones[idx].ToProtoEquipment()) } } } var muBattle sync.Mutex