443 lines
14 KiB
Go
443 lines
14 KiB
Go
package gameserver
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
|
|
"gac-hkrpg-go/internal/common"
|
|
goproto "gac-hkrpg-go/protocol/proto"
|
|
)
|
|
|
|
// PearlSR 005d2c1 protocol/src/protocol.pb.zig: MonsterBattleType.
|
|
// The Go schema retains obfuscated enum labels; zero is NONE, not TRIGGER.
|
|
const monsterBattleTrigger goproto.MonsterBattleType = 1
|
|
|
|
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, err := createBattleInfo(s, 0, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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, err := createBattleInfo(s, 0, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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, err := createBattleInfo(s, 0, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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, err := createBattleInfo(s, 0, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
})
|
|
|
|
// Consume every target carrier in the current schema. Only entities sent
|
|
// as monsters in this scene may start a battle; never fabricate a target.
|
|
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)
|
|
}
|
|
for _, group := range req.AssistMonsterEntityInfo {
|
|
for _, id := range group.GetEntityIdList() {
|
|
addTarget(id)
|
|
}
|
|
}
|
|
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d hits=%v assists=%v groups=%v scene_monsters=%v matched=%v",
|
|
req.CastEntityId, req.CasterId, req.SkillIndex, req.HitTargetEntityIdList,
|
|
req.AssistMonsterEntityIdList, req.AssistMonsterEntityInfo, s.MonsterEntities, targets)
|
|
|
|
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, err := createBattleInfo(s, req.CasterId, req.SkillIndex)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
CURRENT_BATTLE.Store(s.Token, battleInfo)
|
|
|
|
for _, id := range targets {
|
|
rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{
|
|
TargetMonsterEntityId: id,
|
|
MonsterBattleType: monsterBattleTrigger,
|
|
})
|
|
}
|
|
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, error) {
|
|
player := s.JSON()
|
|
if player == nil {
|
|
return nil, fmt.Errorf("battle: player data is not loaded")
|
|
}
|
|
|
|
rules, err := loadBattleRules()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
battleInfo := &goproto.SceneBattleInfo{
|
|
StageId: player.BattleConfig.StageID,
|
|
LogicRandomSeed: randUint32(),
|
|
BattleId: 1,
|
|
RoundsLimit: player.BattleConfig.CycleCount,
|
|
WorldLevel: 6,
|
|
BattleTargetInfo: make(map[uint32]*goproto.BattleTargetList),
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
|
|
if firstAvatarID == 0 {
|
|
firstAvatarID = avatarID
|
|
firstAvatarIdx = avatarIndex
|
|
}
|
|
|
|
battleAvatar, techs := avatar.ToBattleAvatarProto(
|
|
avatarIndex,
|
|
player.FindLightconeFor(avatarID),
|
|
player.FindRelicsFor(avatarID),
|
|
)
|
|
battleInfo.BuffList = append(battleInfo.BuffList, rules.techniqueBuffs(avatar, avatarIndex, firstAvatarIdx, techs)...)
|
|
battleInfo.BattleAvatarList = append(battleInfo.BattleAvatarList, battleAvatar)
|
|
})
|
|
if len(battleInfo.BattleAvatarList) == 0 {
|
|
return nil, fmt.Errorf("battle: lineup has no available avatars")
|
|
}
|
|
|
|
// PearlSR always supplies an entry attack, including Calyx. Resolve the
|
|
// actor from the scene's actual slot IDs, rather than assuming cast_entity
|
|
// identifies a lineup position.
|
|
attackSlot, attackID := firstAvatarIdx, firstAvatarID
|
|
for _, avatar := range battleInfo.BattleAvatarList {
|
|
if casterID != 0 && avatar.Index+1 == casterID {
|
|
attackSlot, attackID = avatar.Index, avatar.Id
|
|
break
|
|
}
|
|
}
|
|
res, err := common.GameRes()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("battle: load avatar resources: %w", err)
|
|
}
|
|
cfg := res.AvatarConfigs[attackID]
|
|
attackBuffID := rules.EntryAttackBuffs[cfg.DamageType]
|
|
if attackBuffID == 0 {
|
|
attackBuffID = cfg.WeaknessBuffID
|
|
}
|
|
if attackBuffID != 0 {
|
|
entrySkillIndex := skillIndex
|
|
if casterID == 0 {
|
|
entrySkillIndex = 1
|
|
}
|
|
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(attackBuffID, attackSlot, map[string]float32{"SkillIndex": float32(entrySkillIndex)}))
|
|
} else {
|
|
return nil, fmt.Errorf("battle: avatar %d has no entry attack in res.json", attackID)
|
|
}
|
|
|
|
// 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,
|
|
TargetIndexList: []uint32{0},
|
|
}
|
|
if blessing.DynamicKey != nil {
|
|
buff.DynamicValues = map[string]float32{blessing.DynamicKey.Key: float32(blessing.DynamicKey.Value)}
|
|
} else {
|
|
buff.DynamicValues = map[string]float32{"SkillIndex": 0}
|
|
}
|
|
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)
|
|
}
|
|
|
|
rules.addTargets(battleInfo, player.BattleConfig.BattleType)
|
|
|
|
// 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))
|
|
|
|
rules.addGlobalPassives(battleInfo, player)
|
|
|
|
// Invasion support belongs to an active challenge, not a custom battle
|
|
// merely reusing the same stage. The scene loader records that state.
|
|
if s.ChallengeStageID != 0 {
|
|
if invasionID := getStageInvasionID(s.ChallengeStageID); invasionID != 0 {
|
|
buffID, ok := rules.InvasionBuffs[invasionID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("battle: invasion %d has no buff resource", invasionID)
|
|
}
|
|
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(buffID, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
|
|
if player.BattleConfig.EnableInvasionSupport {
|
|
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(rules.InvasionSupportBuff, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
|
|
}
|
|
}
|
|
}
|
|
if len(battleInfo.MonsterWaveList) == 0 {
|
|
return nil, fmt.Errorf("battle: stage %d has no configured waves", battleInfo.StageId)
|
|
}
|
|
for i, wave := range battleInfo.MonsterWaveList {
|
|
if len(wave.MonsterList) == 0 {
|
|
return nil, fmt.Errorf("battle: stage %d wave %d is empty", battleInfo.StageId, i)
|
|
}
|
|
}
|
|
Log(fmt.Sprintf("[BATTLE] prepared stage=%d avatars=%d waves=%d buffs=%d challenge_stage=%d", battleInfo.StageId, len(battleInfo.BattleAvatarList), len(battleInfo.MonsterWaveList), len(battleInfo.BuffList), s.ChallengeStageID))
|
|
|
|
return battleInfo, nil
|
|
}
|
|
|
|
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
|
|
}
|