Files
GaCy-GO/internal/gameserver/handlers_battle.go
T

512 lines
17 KiB
Go

package gameserver
import (
"encoding/json"
"fmt"
"os"
"sync"
"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 {
Log(fmt.Sprintf("[BATTLE] StartCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d PropEntityId=%d", req.CocoonId, req.Wave, req.WorldLevel, req.PropEntityId))
rsp.Retcode = 0
rsp.PropEntityId = req.PropEntityId
rsp.CocoonId = req.CocoonId
rsp.Wave = req.Wave
battleInfo := createBattleInfo(s, 0, 0)
CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo
return nil
})
// The Go schema names this message StartQuickCocoonStageCsReq/Rsp
handleNamed("StartQuickCocoonStage", "StartQuickCocoonStageRsp", func(s *Session, req *goproto.StartQuickCocoonStageCsReq, rsp *goproto.StartQuickCocoonStageRsp) error {
Log(fmt.Sprintf("[BATTLE] StartQuickCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d", req.CocoonId, req.Wave, req.WorldLevel))
rsp.Retcode = 0
rsp.CocoonId = req.CocoonId
rsp.Wave = req.Wave
battleInfo := createBattleInfo(s, 0, 0)
CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo
return nil
})
handleNamed("StartQuickFarmElement", "StartQuickFarmElementRsp", func(s *Session, req *goproto.StartQuickFarmElementCsReq, rsp *goproto.StartQuickFarmElementRsp) error {
Log(fmt.Sprintf("[BATTLE] StartQuickFarmElement: FarmID=%d WorldLevel=%d", req.PAOFHFLFFHD, req.WorldLevel))
battleInfo := createBattleInfo(s, 0, 0)
CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.Retcode = 0
rsp.PAOFHFLFFHD = req.PAOFHFLFFHD
rsp.WorldLevel = req.WorldLevel
rsp.BattleInfo = battleInfo
return nil
})
handle("ActiveFarmElement", func(s *Session, req *goproto.ActiveFarmElementCsReq, rsp *goproto.ActiveFarmElementScRsp) error {
Log(fmt.Sprintf("[BATTLE] ActiveFarmElement: EntityId=%d WorldLevel=%d", req.EntityId, req.WorldLevel))
rsp.Retcode = 0
rsp.EntityId = req.EntityId
rsp.WorldLevel = req.WorldLevel
return nil
})
handle("DeactivateFarmElement", func(s *Session, req *goproto.DeactivateFarmElementCsReq, rsp *goproto.DeactivateFarmElementScRsp) error {
Log(fmt.Sprintf("[BATTLE] DeactivateFarmElement: EntityId=%d", req.EntityId))
rsp.Retcode = 0
rsp.EntityId = req.EntityId
return nil
})
handle("CocoonSweep", func(s *Session, req *goproto.CocoonSweepCsReq, rsp *goproto.CocoonSweepScRsp) error {
Log(fmt.Sprintf("[BATTLE] CocoonSweep: CocoonId=%d WorldLevel=%d", req.CocoonId, req.WorldLevel))
rsp.Retcode = 0
rsp.CocoonId = req.CocoonId
rsp.DropData = &goproto.ItemList{}
rsp.MultipleDropData = &goproto.ItemList{}
return nil
})
handle("FarmElementSweep", func(s *Session, req *goproto.FarmElementSweepCsReq, rsp *goproto.FarmElementSweepScRsp) error {
Log(fmt.Sprintf("[BATTLE] FarmElementSweep: FarmID=%d", req.PAOFHFLFFHD))
rsp.Retcode = 0
rsp.PAOFHFLFFHD = req.PAOFHFLFFHD
rsp.MultipleDropData = &goproto.ItemList{}
return nil
})
handle("GetFarmStageGachaInfo", func(s *Session, req *goproto.GetFarmStageGachaInfoCsReq, rsp *goproto.GetFarmStageGachaInfoScRsp) error {
rsp.Retcode = 0
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 ตอบ_info + monster_battle_info
// ให้ client เข้าสู่การต่อสู้กับ target ที่ "ผ่าน filter แบบหลวมๆ"
// filter เดิม: id > 30000 (monster ในฉาก) || id < 1000 (avatar entity ของผู้เล่น!)
// ถ้า targets ว่างและอยู่ใน arena (entry >= 3000000) ยัง "หลอก" target
// เป็น 30001 อีกชั้น
// พอ client ได้_info กับ "monster" ที่จริงคือ prop/NPC/ตัวผู้เล่นเอง
// หรือ monster ที่ไม่มี event_id มันจะเริ่ม transition ที่โหลดไม่สำเร็จ
// → camera ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร
//
// FIX: ยอมรับเฉพาะ entity ที่ server spawn เป็น "monster จริง" ในฉากปัจจุบัน
// (เก็บไว้ใน Session.MonsterEntities ตอน load_scene) ถ้าไม่มี monster จริง
// ถูกโจมตี → ตอบ retcode 0 + cast_entity_id เฉยๆ ไม่เข้า
// client จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร
// ================================================================
handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error {
rsp.CastEntityId = req.CastEntityId
// เฉพาะ monster entity จริงเท่านั้นที่ trigger
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 จริงถูกตี — ไม่เข้า (นี่คือ 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 -> 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 {
// Login queries this even when no battle is active. An absent battle
// is a successful empty result, not RET_BATTLE_STAGE_NOT_MATCH.
rsp.Retcode = 0
if info, ok := CURRENT_BATTLE.Load(s.Token); ok {
rsp.BattleInfo = info
return nil
}
return nil
})
}
// createBattleInfo is the Go port of.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))
// 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,
})
}
// Stage invasion buff (pearl-sr 4.6.53: e.g. Peak 11 stage 4 30511021/30511022 -> buff 3034004)
if invasionID := getStageInvasionID(battleInfo.StageId); invasionID != 0 {
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: 3034000 + invasionID,
Level: 1,
OwnerId: firstAvatarIdx,
WaveFlag: 0xffffffff,
TargetIndexList: []uint32{0},
DynamicValues: map[string]float32{"SkillIndex": 1},
})
// Aha Invasion support buff
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: 3034013,
Level: 1,
OwnerId: firstAvatarIdx,
WaveFlag: 0xffffffff,
TargetIndexList: []uint32{0},
DynamicValues: map[string]float32{"SkillIndex": 1},
})
}
// 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
}
type stageInvasionConfigData struct {
StageInvasionConfig []struct {
StageID uint32 `json:"StageID"`
InvasionID uint32 `json:"InvasionID"`
} `json:"stage_invasion_config"`
}
var (
stageInvasionOnce sync.Once
stageInvasionMap = map[uint32]uint32{}
)
func getStageInvasionID(stageID uint32) uint32 {
stageInvasionOnce.Do(func() {
for _, path := range []string{"resources/StageInvasionConfig.json", "StageInvasionConfig.json"} {
raw, err := os.ReadFile(path)
if err != nil {
continue
}
var cfg stageInvasionConfigData
if err := json.Unmarshal(raw, &cfg); err == nil {
for _, entry := range cfg.StageInvasionConfig {
stageInvasionMap[entry.StageID] = entry.InvasionID
}
break
}
}
})
return stageInvasionMap[stageID]
}
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
}