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 }