package gameserver import ( "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" ) func init() { handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error { battleInfo := createBattleInfo(s, 0, 0) CURRENT_BATTLE.Store(s.Token, battleInfo) rsp.PropEntityId = req.PropEntityId rsp.CocoonId = req.CocoonId rsp.Wave = req.Wave rsp.BattleInfo = battleInfo return nil }) // NOTE: 4.6.51 proto names this message StartQuickCocoonStageCsReq/Rsp // (the Rust code referenced an older QuickStartCocoonStage* name). handleNamed("StartQuickCocoonStage", "StartQuickCocoonStageRsp", func(s *Session, req *goproto.StartQuickCocoonStageCsReq, rsp *goproto.StartQuickCocoonStageRsp) error { battleInfo := createBattleInfo(s, 0, 0) CURRENT_BATTLE.Store(s.Token, battleInfo) rsp.CocoonId = req.CocoonId rsp.Wave = req.Wave rsp.BattleInfo = battleInfo return nil }) handle("SceneEnterStage", func(s *Session, req *goproto.SceneEnterStageCsReq, rsp *goproto.SceneEnterStageScRsp) error { battleInfo := createBattleInfo(s, 0, 0) CURRENT_BATTLE.Store(s.Token, battleInfo) rsp.BattleInfo = battleInfo return nil }) handle("PVEBattleResult", func(s *Session, req *goproto.PVEBattleResultCsReq, rsp *goproto.PVEBattleResultScRsp) error { CURRENT_BATTLE.Delete(s.Token) rsp.EndStatus = req.EndStatus rsp.BattleId = req.BattleId rsp.StageId = req.StageId return nil }) // ================================================================ // SceneCastSkill — THE CAMERA-DETACH BUG FIX // (บัคกล้องหลุดจากตัวละครตอนกดตี) // // อาการ: กดโจมตีใน overworld แล้วกล้องหลุดออกจากตัวละคร // // สาเหตุ (จากโค้ด Rust เดิม): handler ตอบ battle_info + monster_battle_info // ให้ client เข้าสู่การต่อสู้กับ target ที่ "ผ่าน filter แบบหลวมๆ" // filter เดิม: id > 30000 (monster ในฉาก) || id < 1000 (avatar entity ของผู้เล่น!) // ถ้า targets ว่างและอยู่ใน arena (entry >= 3000000) ยัง "หลอก" target // เป็น 30001 อีกชั้น // พอ client ได้ battle_info กับ "monster" ที่จริงคือ prop/NPC/ตัวผู้เล่นเอง // หรือ monster ที่ไม่มี event_id มันจะเริ่ม battle transition ที่โหลดไม่สำเร็จ // → battle camera ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร // // FIX: ยอมรับเฉพาะ entity ที่ server spawn เป็น "monster จริง" ในฉากปัจจุบัน // (เก็บไว้ใน Session.MonsterEntities ตอน load_scene) ถ้าไม่มี monster จริง // ถูกโจมตี → ตอบ retcode 0 + cast_entity_id เฉยๆ ไม่เข้า battle // client จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร // ================================================================ handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error { rsp.CastEntityId = req.CastEntityId // เฉพาะ monster entity จริงเท่านั้นที่ trigger battle var targets []uint32 seen := map[uint32]bool{} addTarget := func(id uint32) { if _, isMonster := s.MonsterEntities[id]; isMonster && !seen[id] { seen[id] = true targets = append(targets, id) } } for _, id := range req.HitTargetEntityIdList { addTarget(id) } for _, id := range req.AssistMonsterEntityIdList { addTarget(id) } if len(targets) == 0 { // ไม่มี monster จริงถูกตี — ไม่เข้า battle (นี่คือ fix หลัก) SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d -> no real monster target (battle skipped)", req.CastEntityId, req.CasterId, req.SkillIndex) rsp.Retcode = 0 return nil } SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> battle with %d monster(s)", req.CastEntityId, req.CasterId, len(targets)) battleInfo := createBattleInfo(s, req.CasterId, req.SkillIndex) CURRENT_BATTLE.Store(s.Token, battleInfo) for _, id := range targets { rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{ TargetMonsterEntityId: id, }) } rsp.BattleInfo = battleInfo return nil }) // ตอบ MP cost ให้ client (ตอบ cast_entity_id กลับ, ไม่ตัด MP จริง) handle("SceneCastSkillCostMp", func(s *Session, req *goproto.SceneCastSkillCostMpCsReq, rsp *goproto.SceneCastSkillCostMpScRsp) error { rsp.Retcode = 0 rsp.CastEntityId = req.CastEntityId return nil }) handle("GetCurBattleInfo", func(s *Session, req *goproto.GetCurBattleInfoCsReq, rsp *goproto.GetCurBattleInfoScRsp) error { if info, ok := CURRENT_BATTLE.Load(s.Token); ok { rsp.Retcode = 0 rsp.BattleInfo = info return nil } if player := s.JSON(); player != nil && len(player.BattleConfig.Monsters) > 0 { battleInfo := createBattleInfo(s, 0, 0) CURRENT_BATTLE.Store(s.Token, battleInfo) rsp.Retcode = 0 rsp.BattleInfo = battleInfo return nil } rsp.Retcode = 2000 // RET_BATTLE_STAGE_NOT_MATCH return nil }) } // createBattleInfo is the Go port of battle.rs create_battle_info. func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBattleInfo { player := s.JSON() if player == nil { Log("data is not set!") return &goproto.SceneBattleInfo{} } battleInfo := &goproto.SceneBattleInfo{ StageId: player.BattleConfig.StageID, LogicRandomSeed: randUint32(), BattleId: 1, RoundsLimit: player.BattleConfig.CycleCount, WorldLevel: 6, } isCalyx := casterID == 0 && skillIndex == 0 hasDahlia := false firstAvatarID := uint32(0) firstAvatarIdx := uint32(9999) lineup := func(yield func(index, avatarID uint32)) { if player.BattleConfig.CustomBattleLineup != nil { for _, idx := range sortedLineup(player.BattleConfig.CustomBattleLineup) { yield(idx, player.BattleConfig.CustomBattleLineup[idx]) } return } for _, idx := range player.SortedLineupKeys() { yield(idx, player.Lineups[idx]) } } // avatars lineup(func(avatarIndex, lineupID uint32) { if firstAvatarID == 0 { firstAvatarID = lineupID firstAvatarIdx = avatarIndex } if !hasDahlia { hasDahlia = lineupID == 1321 } avatarID := lineupID isTrailblazer := lineupID == 8001 isMarch := lineupID == 1001 if isTrailblazer { avatarID = player.MainCharacter.ID() } else if isMarch { avatarID = player.MarchType.ID() } avatar, ok := player.Avatars[avatarID] if !ok { return } battleAvatar, techs := avatar.ToBattleAvatarProto( avatarIndex, player.FindLightconeFor(avatarID), player.FindRelicsFor(avatarID), ) battleInfo.BuffList = append(battleInfo.BuffList, techs...) res, err := common.GameRes() if casterID > 0 && avatarIndex == casterID-1 && err == nil { if avatarConfig, okc := res.AvatarConfigs[avatarID]; okc && !containsU32(avatar.Techniques, 1000119) { battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ Id: avatarConfig.WeaknessBuffID, Level: 1, OwnerId: avatarIndex, WaveFlag: 0xffffffff, DynamicValues: map[string]float32{"SkillIndex": float32(skillIndex)}, }) } } battleInfo.BattleAvatarList = append(battleInfo.BattleAvatarList, battleAvatar) // hardcoded march buff if avatar.AvatarID == 1224 { battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ Id: 122401, Level: 3, WaveFlag: 0xffffffff, OwnerId: avatarIndex, DynamicValues: map[string]float32{"#ADF_1": 3, "#ADF_2": 3}, TargetIndexList: []uint32{0}, }) } }) // resolve character-specific technique buffs dynamically firstAvatarAttackID := uint32(0) if res, err := common.GameRes(); err == nil { if cfg, ok := res.AvatarConfigs[firstAvatarID]; ok { firstAvatarAttackID = cfg.WeaknessBuffID } } len0 := len(battleInfo.BuffList) hasReplaced := false var dahliaBuffs []*goproto.BattleBuff for i, buff := range battleInfo.BuffList { isLast := i == len0-1 if buff.Id == 141202 || buff.Id == 141403 { buff.OwnerId = firstAvatarIdx continue } if hasDahlia && isCalyx && buff.Id >= 1000111 && buff.Id <= 1000117 && !hasReplaced && firstAvatarAttackID != 0 { buff.Id = firstAvatarAttackID buff.OwnerId = firstAvatarIdx hasReplaced = true } if hasDahlia && isCalyx && !hasReplaced && firstAvatarAttackID != 0 && isLast { dahliaBuffs = append(dahliaBuffs, &goproto.BattleBuff{ Id: 1000121, Level: 1, WaveFlag: 0xffffffff, TargetIndexList: []uint32{firstAvatarIdx}, }, &goproto.BattleBuff{ Id: firstAvatarAttackID, Level: 1, WaveFlag: 0xffffffff, TargetIndexList: []uint32{firstAvatarIdx}, DynamicValues: map[string]float32{"SkillIndex": float32(firstAvatarIdx)}, }, ) } } battleInfo.BuffList = append(battleInfo.BuffList, dahliaBuffs...) // custom stats for avatars for _, stat := range player.BattleConfig.CustomStats { for _, avatar := range battleInfo.BattleAvatarList { if len(avatar.RelicList) == 0 { avatar.RelicList = append(avatar.RelicList, &goproto.BattleRelic{ Id: 61011, MainAffixId: 1, Level: 1, }) } matched := false for _, sub := range avatar.RelicList[0].SubAffixList { if sub.AffixId == stat.SubAffixID { sub.Cnt = stat.Count matched = true break } } if !matched { avatar.RelicList[0].SubAffixList = append(avatar.RelicList[0].SubAffixList, &goproto.RelicAffix{ AffixId: stat.SubAffixID, Cnt: stat.Count, Step: stat.Step, }) } } } // blessings for _, blessing := range player.BattleConfig.Blessings { buff := &goproto.BattleBuff{ Id: blessing.ID, Level: blessing.Level, WaveFlag: 0xffffffff, OwnerId: 0xffffffff, } if blessing.DynamicKey != nil { buff.DynamicValues = map[string]float32{blessing.DynamicKey.Key: float32(blessing.DynamicKey.Value)} } else { buff.DynamicValues = map[string]float32{} } for _, dv := range blessing.DynamicValues { if _, exists := buff.DynamicValues[dv.Key]; exists { continue } buff.DynamicValues[dv.Key] = float32(dv.Value) } battleInfo.BuffList = append(battleInfo.BuffList, buff) } // pf score object if player.BattleConfig.BattleType == common.BattleTypePF { if battleInfo.StageId >= 30309011 { battleInfo.BattleTargetInfo[1] = &goproto.BattleTargetList{ BattleTargetList: []*goproto.BattleTarget{{Id: 10003}}, } } else { battleInfo.BattleTargetInfo[1] = &goproto.BattleTargetList{ BattleTargetList: []*goproto.BattleTarget{{Id: 10002}}, } } for i := uint32(2); i <= 4; i++ { battleInfo.BattleTargetInfo[i] = &goproto.BattleTargetList{} } battleInfo.BattleTargetInfo[5] = &goproto.BattleTargetList{ BattleTargetList: []*goproto.BattleTarget{{Id: 2001}, {Id: 2002}}, } } // Apocalyptic Shadow if player.BattleConfig.BattleType == common.BattleTypeAS { battleInfo.BattleTargetInfo[1] = &goproto.BattleTargetList{ BattleTargetList: []*goproto.BattleTarget{{Id: 90005}}, } } // SU if player.BattleConfig.BattleType == common.BattleTypeSU { battleInfo.BattleEvent = append(battleInfo.BattleEvent, &goproto.BattleEventBattleInfo{ BattleEventId: player.BattleConfig.PathResonanceID, Status: &goproto.BattleEventProperty{ SpBar: &goproto.SpBarInfo{CurSp: 10000, MaxSp: 10000}, }, }) } // monsters battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters) battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList)) for _, wave := range battleInfo.MonsterWaveList { wave.BattleStageId = battleInfo.StageId } // global buffs hasCastorice, hasSilverWolf := false, false for _, buff := range battleInfo.BuffList { if buff.Id == 140703 { hasCastorice = true } if buff.Id == 150602 { hasSilverWolf = true } } if !hasCastorice && (player.EnableCastoriceGlobal == nil || *player.EnableCastoriceGlobal) { battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ Id: 140703, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff, }) } if !hasSilverWolf && (player.EnableSWGlobal == nil || *player.EnableSWGlobal) { battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ Id: 150602, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff, }) } // Aha Global Passive (151104) — always on, same as the Rust build battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ Id: 151104, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff, }) return battleInfo } func sortedLineup(m map[uint32]uint32) []uint32 { keys := make([]uint32, 0, len(m)) for k := range m { keys = append(keys, k) } sortU32Slice(keys) return keys } func sortU32Slice(s []uint32) { for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j] < s[j-1]; j-- { s[j], s[j-1] = s[j-1], s[j] } } } func containsU32(list []uint32, v uint32) bool { for _, x := range list { if x == v { return true } } return false }