package gameserver import ( "encoding/json" "fmt" "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 monsterBattleDirectDieSkip goproto.MonsterBattleType = 3 // PearlSR protocol.pb.zig: MONSTER_BATTLE_TYPE_NO_BATTLE. monsterBattleNoBattle goproto.MonsterBattleType = 4 ) 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 source, sourceErr := s.cocoonBattleConfig(req.CocoonId, req.WorldLevel, req.Wave) if sourceErr != nil { Log("[BATTLE] " + sourceErr.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } battleInfo, err := createBattleInfo(s, 0, 0, source) if err != nil { Log("[BATTLE] " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } 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 source, sourceErr := s.cocoonBattleConfig(req.CocoonId, req.WorldLevel, req.Wave) if sourceErr != nil { Log("[BATTLE] " + sourceErr.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } battleInfo, err := createBattleInfo(s, 0, 0, source) if err != nil { Log("[BATTLE] " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } 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)) source, sourceErr := s.cocoonBattleConfig(req.PAOFHFLFFHD, req.WorldLevel, 0) if sourceErr != nil { Log("[BATTLE] " + sourceErr.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } battleInfo, err := createBattleInfo(s, 0, 0, source) if err != nil { Log("[BATTLE] " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } 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 { data, err := loadWorldBattleResources() if err != nil { rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } source, err := s.eventBattleConfig(req.EventId, data.WorldLevel) if err != nil { Log("[BATTLE] " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } battleInfo, err := createBattleInfo(s, 0, 0, source) if err != nil { Log("[BATTLE] " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } CURRENT_BATTLE.Store(s.Token, battleInfo) rsp.BattleInfo = battleInfo return nil }) handle("PVEBattleResult", func(s *Session, req *goproto.PVEBattleResultCsReq, rsp *goproto.PVEBattleResultScRsp) error { if err := saveBattleResultCapture(req); err != nil { Log(fmt.Sprintf("[BATTLE CAPTURE] result: %v", err)) } current, active := CURRENT_BATTLE.Load(s.Token) active = active && current.StageId == req.StageId if active && current.EIGMKOJOEEI == nil { CURRENT_BATTLE.Delete(s.Token) } rsp.EndStatus = req.EndStatus rsp.BattleId = req.BattleId rsp.StageId = req.StageId if req.Stt != nil { statusStr := "WIN" switch req.EndStatus { case goproto.BattleEndStatus_BATTLE_END_LOSE: statusStr = "LOSE" case goproto.BattleEndStatus_BATTLE_END_QUIT: statusStr = "QUIT" } report := common.BattleReportFromStatistics(req.Stt, req.BattleId, req.StageId, statusStr) if active { common.ApplyBattleReportContext(report, current) } if err := common.SaveBattleReport(report); err != nil { Log(fmt.Sprintf("[DAMAGE_ANALYST] Failed to save battle report: %v", err)) } else { Log(fmt.Sprintf("[DAMAGE_ANALYST] Battle report captured: BattleID=%d Dmg=%.1f Cycles=%d Turns=%d Status=%s", req.BattleId, report.TeamTotalDamage, report.RoundCnt, report.TotalBattleTurns, statusStr)) } } if active && current.EIGMKOJOEEI != nil { if err := finishCurrencyWarsBattle(s, req, current); err != nil { PacketLog("CW_SETTLEMENT rejected: " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_CONF_MISS) } return nil } return finishChallengeBattle(s, req, rsp, active) }) // 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) } } addTarget(req.CasterId) 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) props := s.hitSceneProps(req) if len(targets) == 0 { if len(props) > 0 { // Complete the cast before removing props used by its animation. if err := s.Send(rsp); err != nil { return err } if err := s.breakSceneProps(props); err != nil { return err } return errResponseAlreadySent } if s.ChallengeStageID != 0 { SceneDebugLog("[CHALLENGE] attack without monster target request=%s", req.String()) } // ไม่มี 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)) // PearlSR getBattleType acknowledges non-attacking techniques without // starting a battle. The monster still needs a NO_BATTLE result. if req.SkillIndex == 1 { rules, err := common.LoadTechniqueRules() if err != nil { return err } avatarID := s.techniqueAvatar(req.CasterId) for _, id := range rules.NonAttackTechniques { if avatarID == id { for _, target := range targets { rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{TargetMonsterEntityId: target, MonsterBattleType: monsterBattleNoBattle}) } SceneDebugLog("[TECHNIQUE] non-battle avatar=%d targets=%v", avatarID, targets) return nil } } } defeated := s.instantDefeatTargets(req, targets) if len(defeated) > 0 { removed := map[uint32]bool{} for _, id := range defeated { removed[id] = true rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{TargetMonsterEntityId: id, MonsterBattleType: monsterBattleDirectDieSkip}) } remaining := make([]uint32, 0, len(targets)) for _, id := range targets { if !removed[id] { remaining = append(remaining, id) } } targets = remaining if len(targets) == 0 { if err := s.Send(rsp); err != nil { return err } if err := s.removeSceneMonsters(defeated); err != nil { return err } if err := s.breakSceneProps(props); err != nil { return err } return errResponseAlreadySent } } source, sourceErr := s.worldEncounterConfig(targets) if sourceErr != nil { rsp.MonsterBattleInfo = nil Log("[BATTLE] " + sourceErr.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) return nil } battleInfo, err := createBattleInfo(s, req.CasterId, req.SkillIndex, source) if err != nil { Log("[BATTLE] " + err.Error()) rsp.Retcode = uint32(goproto.Retcode_RET_BATTLE_STAGE_NOT_MATCH) rsp.MonsterBattleInfo = nil return nil } CURRENT_BATTLE.Store(s.Token, battleInfo) for _, id := range targets { rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{ TargetMonsterEntityId: id, MonsterBattleType: monsterBattleTrigger, }) } rsp.BattleInfo = battleInfo if len(defeated) > 0 || len(props) > 0 { if err := s.Send(rsp); err != nil { return err } if err := s.removeSceneMonsters(defeated); err != nil { return err } if err := s.breakSceneProps(props); err != nil { return err } return errResponseAlreadySent } return nil }) // Cost and recovery use the same session MP as lineup and consumables. handle("SceneCastSkillCostMp", func(s *Session, req *goproto.SceneCastSkillCostMpCsReq, rsp *goproto.SceneCastSkillCostMpScRsp) error { rsp.CastEntityId = req.CastEntityId code, err := s.payTechnique(req.CastEntityId, req.SkillIndex, req.AttackedByEntityId) rsp.Retcode = code if err != nil || code != 0 { return err } // Finish the pending ability's cost callback before publishing its MP. if err := s.Send(rsp); err != nil { return err } if err := s.Send(&goproto.SceneCastSkillMpUpdateScNotify{CastEntityId: req.CastEntityId, Mp: *s.techniqueMP}); err != nil { return err } return errResponseAlreadySent }) 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, sources ...*common.BattleConfig) (*goproto.SceneBattleInfo, error) { player := s.JSON() if player == nil { return nil, fmt.Errorf("battle: player data is not loaded") } selected := *player switch { case s.ChallengeBattleConfig != nil: selected.BattleConfig = *s.ChallengeBattleConfig case len(sources) > 0 && sources[0] != nil: selected.BattleConfig = *sources[0] default: return nil, fmt.Errorf("battle source was not selected from a Client resource or a validated Cocoon request") } player = &selected supportedRelics, err := supportedRelicIDs() if err != nil { return nil, fmt.Errorf("battle: load Client Relic catalog: %w", err) } worldResources, err := loadWorldBattleResources() if err != nil { return nil, err } worldLevel := worldResources.WorldLevel if player.BattleConfig.WorldLevel != nil { worldLevel = *player.BattleConfig.WorldLevel } 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: worldLevel, 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), filterSupportedRelics(player.FindRelicsFor(avatarID), supportedRelics), ) battleAvatar.WorldLevel = worldLevel 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 } _, attackedByMonster := s.MonsterEntities[casterID] if attackedByMonster { // An ambush has no player entry attack. } else 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) // Official challenges add their resource-defined corruption. Custom battles // retain only the corruption actually supplied in their buff list. 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 err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx); err != nil { return nil, err } 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() { raw, err := common.ReadResourceFile("resources/StageInvasionConfig.json", "StageInvasionConfig.json") if err != nil { return } var cfg stageInvasionConfigData if err := json.Unmarshal(raw, &cfg); err == nil { for _, entry := range cfg.StageInvasionConfig { stageInvasionMap[entry.StageID] = entry.InvasionID } } }) 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 }