package gameserver import ( "fmt" "google.golang.org/protobuf/proto" "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" ) // Challenge flows use the generated request and response layouts. Command // numbers are resolved through the current protocol table at registration/send. type decodedChallengeStart struct { challengeID uint32 stageIndex uint32 isSingleStage bool firstAvatars []uint32 secondAvatars []uint32 thirdAvatars []uint32 stageInfoList [][]uint32 buffID uint32 buffIDs []uint32 } // --------------------------------------------------------------------------- // protobuf wire helpers (mirroring the Rust prost decoding loops) // --------------------------------------------------------------------------- func skipWireField(wireType uint32, buf *[]byte) { b := *buf switch wireType { case 0: _, n := readUvarint(b) if n == 0 { *buf = nil } else { *buf = b[n:] } case 1: if len(b) >= 8 { *buf = b[8:] } else { *buf = nil } case 2: l, n := readUvarint(b) if n == 0 || int(l) > len(b[n:]) { *buf = nil } else { *buf = b[n+int(l):] } case 5: if len(b) >= 4 { *buf = b[4:] } else { *buf = nil } default: *buf = nil } } func decodeStartChallengeReq(payload []byte) decodedChallengeStart { var pbReq goproto.StartChallengeCsReq if err := proto.Unmarshal(payload, &pbReq); err != nil { return decodedChallengeStart{} } req := decodedChallengeStart{challengeID: pbReq.ChallengeId, firstAvatars: pbReq.FirstLineup, secondAvatars: pbReq.SecondLineup} if len(req.firstAvatars) == 0 { for _, avatar := range pbReq.AvatarLineupFirst { if avatar.Id != 0 { req.firstAvatars = append(req.firstAvatars, avatar.Id) } } } if len(req.secondAvatars) == 0 { for _, avatar := range pbReq.AvatarLineupSecond { if avatar.Id != 0 { req.secondAvatars = append(req.secondAvatars, avatar.Id) } } } if pbReq.StageInfo != nil { if info := pbReq.StageInfo.BossInfo; info != nil { req.buffID = info.BuffOne req.buffIDs = []uint32{info.BuffOne, info.BuffTwo} } if info := pbReq.StageInfo.StoryInfo; info != nil && req.buffID == 0 { req.buffID = info.BuffOne req.buffIDs = []uint32{info.BuffOne, info.BuffTwo} } } return req } func decodeStartChallengeTierceReq(payload []byte) decodedChallengeStart { var pbReq goproto.StartChallengeTierceCsReq if err := proto.Unmarshal(payload, &pbReq); err != nil { return decodedChallengeStart{} } req := decodedChallengeStart{challengeID: pbReq.ChallengeId, stageIndex: pbReq.FINOCEGDBDI, isSingleStage: pbReq.MPKMIKBFEHG} for index, stage := range pbReq.LineupTierceList { req.buffIDs = append(req.buffIDs, stage.BuffId) var avatars []uint32 for _, avatar := range stage.TierceAvatarIdList { if avatar.Id != 0 { avatars = append(avatars, avatar.Id) } } req.stageInfoList = append(req.stageInfoList, avatars) if uint32(index) == req.stageIndex { req.buffID = stage.BuffId } } if len(req.stageInfoList) > 0 { req.firstAvatars = req.stageInfoList[0] } if len(req.stageInfoList) > 1 { req.secondAvatars = req.stageInfoList[1] } if len(req.stageInfoList) > 2 { req.thirdAvatars = req.stageInfoList[2] } return req } func decodeSetChallengeTierceLineupReq(payload []byte) (uint32, [][]uint32, []uint32) { var req goproto.SetChallengeTierceLineupCsReq if err := proto.Unmarshal(payload, &req); err != nil { return 0, nil, nil } var stages [][]uint32 var buffs []uint32 for _, stage := range req.StageInfoList { buffs = append(buffs, stage.BuffId) var avatars []uint32 for _, avatar := range stage.TierceAvatarIdList { if avatar.Id != 0 { avatars = append(avatars, avatar.Id) } } stages = append(stages, avatars) } return req.ChallengeId, stages, buffs } // --------------------------------------------------------------------------- // config setup shared by start handlers // --------------------------------------------------------------------------- // Build challenge configuration from the selected resource stage, never from // Freesr custom monsters. Only the current session owns this configuration. func setChallengeBattleConfig(s *Session, avatars []uint32, challengeID, eventID uint32, buffs []uint32) error { stage, ok := getChallengeData().Stages[eventID] if !ok || len(stage.Monsters) == 0 { return fmt.Errorf("challenge stage %d has no resource waves", eventID) } config := common.BattleConfig{StageID: eventID, CustomBattleLineup: map[uint32]uint32{}} for i, id := range avatars { config.CustomBattleLineup[uint32(i)] = id } switch { case challengeID >= 20000 && challengeID < 30000: config.BattleType = common.BattleTypePF case challengeID >= 30000: config.BattleType = common.BattleTypeAS default: config.BattleType = common.BattleTypeDefault } // ChallengeCountDown is supplied by the installed challenge table. configID := challengeID if tierce, ok := getChallengeData().Tierce[challengeID]; ok { configID = tierce.PreChallengeID } config.CycleCount = getChallengeData().Countdowns[configID] if config.CycleCount == 0 { // AS/PF rows omit ChallengeCountDown. Their limits come from the // sourced challenge rules, not the user's custom battle configuration. rules, err := loadBattleRules() if err != nil { return err } config.CycleCount = rules.ChallengeRoundLimits[config.BattleType] if config.CycleCount == 0 { return fmt.Errorf("missing challenge round limit for %s", config.BattleType) } } for _, wave := range stage.Monsters { if len(wave) == 0 { return fmt.Errorf("challenge stage %d has an empty wave", eventID) } monsters := make([]common.Monster, 0, len(wave)) for _, id := range wave { monsters = append(monsters, common.Monster{Level: stage.Level, MonsterID: id}) } config.Monsters = append(config.Monsters, monsters) } seen := map[uint32]bool{} for _, id := range buffs { if id != 0 && !seen[id] { config.Blessings = append(config.Blessings, common.BattleBuffJson{ID: id, Level: 1}) seen[id] = true } } s.ChallengeBattleConfig = &config SceneDebugLog("[CHALLENGE] battle source=resource challenge=%d stage=%d waves=%v buffs=%v", challengeID, eventID, stage.Monsters, buffs) return nil } func lineupInfoFromAvatars(avatars []uint32, planeID uint32, extraType goproto.ExtraLineupType, index uint32) *goproto.LineupInfo { custom := map[uint32]uint32{} for i, aid := range avatars { custom[uint32(i)] = aid } lineup := common.ToLineupInfo(custom) lineup.PlaneId = planeID lineup.ExtraLineupType = extraType lineup.Index = index return lineup } func challengeAvatarsFallback(s *Session) []uint32 { if player := s.JSON(); player != nil { var out []uint32 for _, slot := range player.SortedLineupKeys() { if v := player.Lineups[slot]; v != 0 { out = append(out, v) } } return out } return nil } func dedupeTruncate4(in []uint32) []uint32 { seen := map[uint32]bool{} var out []uint32 for _, v := range in { if !seen[v] { seen[v] = true out = append(out, v) } if len(out) == 4 { break } } return out } // handleStartChallenge ports handle_start_challenge: respond cmd 1787 with // retcode(3) + cur_challenge(5) + lineup_list(6) + scene(10) + stage_info(13). func handleStartChallenge(s *Session, payload []byte) error { req := decodeStartChallengeReq(payload) if req.challengeID == 0 { return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } Log("handle_start_challenge: challenge_id=" + itoa(int(req.challengeID))) chosenAvatars := req.firstAvatars if len(chosenAvatars) == 0 { chosenAvatars = challengeAvatarsFallback(s) } // cap team at 4 (client TeamManager IndexOutOfRange otherwise) chosenAvatars = dedupeTruncate4(chosenAvatars) baseID := req.challengeID cd := getChallengeData() stage, ok := cd.Challenges[baseID] if !ok { return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } entrance, group, monster, event := stage.Entrance, stage.Group1, stage.Monster1, stage.Event1 buff := req.buffID if buff == 0 { buff = stage.Buff } Log("[CHALLENGE] Start: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")") Log("[CHALLENGE] Arena: entrance=" + itoa(int(entrance)) + " group=" + itoa(int(group)) + " monster=" + itoa(int(monster)) + " event=" + itoa(int(event))) previousConfig := s.ChallengeBattleConfig if err := setChallengeBattleConfig(s, chosenAvatars, req.challengeID, event, []uint32{buff, cd.Challenges[baseID].Buff}); err != nil { SceneDebugLog("[CHALLENGE] start rejected: %v", err) return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } sceneInfo, _, err := loadChallengeScene(s, entrance, group, monster, event, chosenAvatars) if err != nil { s.ChallengeBattleConfig = previousConfig SceneDebugLog("[CHALLENGE] scene rejected: %v", err) return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } var lineups []*goproto.LineupInfo lineups = append(lineups, lineupInfoFromAvatars(chosenAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), 0)) secondAvatars := req.secondAvatars if len(secondAvatars) == 0 { secondAvatars = chosenAvatars } secondAvatars = dedupeTruncate4(secondAvatars) lineups = append(lineups, lineupInfoFromAvatars(secondAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType_ExtraLineupType_LineupChallenge2, 0)) curChallenge := &goproto.CurChallenge{ ChallengeId: req.challengeID, Status: goproto.ChallengeStatus_CHALLENGE_DOING, RoundCount: 0, ScoreId: 0, ScoreTwo: 0, ExtraLineupType: goproto.ExtraLineupType(1), } // PearlSR 005d2c12 challenge_mgr.zig creates this mode-specific state // before the Client recovers its challenge instance. Omitting it causes // ChallengeBossInstance._SyncExtraInfo to dereference a missing message. mazeBuff := cd.Challenges[baseID].Buff buffs := make([]uint32, 0, 2) for _, id := range []uint32{req.buffID, mazeBuff} { if id != 0 { buffs = append(buffs, id) } } switch { case req.challengeID > 20000 && req.challengeID < 30000: curChallenge.StageInfo = &goproto.ChallengeCurBuffInfo{ CurStoryBuffs: &goproto.ChallengeStoryBuffList{BuffList: buffs}, } case req.challengeID > 30000: curChallenge.StageInfo = &goproto.ChallengeCurBuffInfo{ CurBossBuffs: &goproto.ChallengeBossBuffList{BuffList: buffs, ChallengeBossConst: 1}, } } rooms := []challengeRoom{resourceRoom(stage, false, chosenAvatars, req.buffID)} if stage.Entrance2 != 0 && stage.Event2 != 0 { var secondBuff uint32 if len(req.buffIDs) > 1 { secondBuff = req.buffIDs[1] } rooms = append(rooms, resourceRoom(stage, true, secondAvatars, secondBuff)) } s.ChallengeRun = &challengeRun{ID: req.challengeID, Rooms: rooms, Scores: make([]uint32, len(rooms)), Cur: curChallenge} rsp := &goproto.StartChallengeScRsp{ Retcode: 0, CurChallenge: curChallenge, LineupList: lineups, Scene: sceneInfo, } SceneDebugLog("[CHALLENGE] start id=%d mapped=%d entry=%d floor=%d world=%d mode=%d buff=%v storyState=%t bossState=%t", req.challengeID, baseID, sceneInfo.EntryId, sceneInfo.FloorId, sceneInfo.WorldId, sceneInfo.GameModeType, buffs, curChallenge.GetStageInfo().GetCurStoryBuffs() != nil, curChallenge.GetStageInfo().GetCurBossBuffs() != nil) return s.Send(rsp) } // handleStartChallengeTierce uses the current Tierce request schema. func handleStartChallengeTierce(s *Session, payload []byte) error { req := decodeStartChallengeTierceReq(payload) if req.challengeID == 0 { return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } baseID := req.challengeID Log("handle_start_challenge_tierce: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")") if err := rememberTierceTeams(s, req.challengeID, req.stageInfoList, req.buffIDs, req.isSingleStage, req.stageIndex); err != nil { return err } req.stageInfoList = s.TierceTeams[req.challengeID] req.buffIDs = s.TierceBuffs[req.challengeID] if int(req.stageIndex) < len(req.buffIDs) { req.buffID = req.buffIDs[req.stageIndex] } if req.stageIndex >= tierceNodeCount { return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } chosenAvatars := req.stageInfoList[req.stageIndex] if len(chosenAvatars) == 0 { return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_LINEUP_IS_EMPTY)}) } cd := getChallengeData() // PearlSR challenge_mgr.zig: Tierce nodes 0/1 use the preceding // ChallengeMazeConfig's first/second node; node 2 uses Tierce config. tierce, tierceOK := cd.Tierce[req.challengeID] base, baseOK := cd.Challenges[tierce.PreChallengeID] if !tierceOK || !baseOK || req.stageIndex > 2 { return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } entrance, group, monster, event := base.Entrance, base.Group1, base.Monster1, base.Event1 switch req.stageIndex { case 1: entrance, group, monster, event = base.Entrance2, base.Group2, base.Monster2, base.Event2 case 2: entrance, group, monster, event = tierce.Entrance, tierce.Group, tierce.Monster, tierce.Event } previousConfig := s.ChallengeBattleConfig if err := setChallengeBattleConfig(s, chosenAvatars, req.challengeID, event, []uint32{req.buffID, base.Buff}); err != nil { SceneDebugLog("[CHALLENGE] tierce start rejected: %v", err) return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } sceneInfo, _, err := loadChallengeScene(s, entrance, group, monster, event, chosenAvatars) if err != nil { s.ChallengeBattleConfig = previousConfig SceneDebugLog("[CHALLENGE] tierce scene rejected: %v", err) return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } // The response carries the active node's lineup, as upstream does. // Other nodes are cached for subsequent requests, not installed as active teams. tierceLineupList := []*goproto.LineupInfo{lineupInfoFromAvatars(chosenAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), 0)} SceneDebugLog("[CHALLENGE] tierce id=%d node=%d entry=%d floor=%d group=%d event=%d avatars=%v", req.challengeID, req.stageIndex, entrance, sceneInfo.FloorId, group, event, chosenAvatars) // Generated response resolves its command ID through Session.Send. rsp := &goproto.StartChallengeTierceScRsp{ Retcode: 0, Scene: sceneInfo, CurTierceChallenge: &goproto.CurTierceChallenge{ LineupList: tierceLineupList, ChallengeId: req.challengeID, FINOCEGDBDI: req.stageIndex, MPKMIKBFEHG: req.isSingleStage, }, } teams := req.stageInfoList if len(teams) == 0 { teams = s.TierceTeams[req.challengeID] } rooms := make([]challengeRoom, tierceNodeCount) for node := range rooms { var team []uint32 if node < len(teams) { team = teams[node] } if uint32(node) == req.stageIndex { team = chosenAvatars } var selectedBuff uint32 if node < len(req.buffIDs) { selectedBuff = req.buffIDs[node] } rooms[node] = resourceRoom(base, node == 1, team, selectedBuff) if node == 2 { rooms[node].Entrance, rooms[node].Group, rooms[node].Monster, rooms[node].Event = tierce.Entrance, tierce.Group, tierce.Monster, tierce.Event } } s.ChallengeRun = &challengeRun{ID: req.challengeID, Tierce: true, Single: req.isSingleStage, Node: req.stageIndex, Rooms: rooms, Scores: make([]uint32, len(rooms)), TierceCur: rsp.CurTierceChallenge} // A single-node retry keeps the other nodes' measured best results. if req.isSingleStage { if saved := s.JSON().TierceProgress[req.challengeID]; saved != nil { for node, result := range saved.Nodes { if node < tierceNodeCount && node != req.stageIndex { s.ChallengeRun.Scores[node] = result.Score s.ChallengeRun.Results = append(s.ChallengeRun.Results, tierceNodeResult(node, result, s.TierceTeams[req.challengeID][node])) } } } } rsp.CurTierceChallenge.LineupList = []*goproto.LineupInfo{s.ChallengeRun.activeLineup(sceneInfo.PlaneId)} return s.Send(rsp) } func handleSetChallengeTierceLineup(s *Session, payload []byte) error { challengeID, stages, buffs := decodeSetChallengeTierceLineupReq(payload) if challengeID == 0 { return s.Send(&goproto.SetChallengeTierceLineupScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)}) } if err := rememberTierceTeams(s, challengeID, stages, buffs, false, 0); err != nil { return err } return s.Send(&goproto.SetChallengeTierceLineupScRsp{}) } func handleLeaveChallenge(s *Session, _ []byte) error { return leaveChallengeScene(s, &goproto.LeaveChallengeScRsp{}) } func handleLeaveChallengeTierce(s *Session, _ []byte) error { return leaveChallengeScene(s, &goproto.LeaveChallengeTierceScRsp{}) } func leaveChallengeScene(s *Session, response proto.Message) error { player := s.JSON() if player == nil || player.Scene.EntryID == 0 { return fmt.Errorf("challenge return scene is unavailable") } // loadChallengeScene preserves the player's saved overworld scene. entryID := player.Scene.EntryID CURRENT_BATTLE.Delete(s.Token) if err := s.Send(&goproto.QuitBattleScNotify{}); err != nil { return err } if _, err := loadScene(s, entryID, true, true, nil, nil); err != nil { return err } if err := refreshLineup(s); err != nil { return err } return s.Send(response) } func handleTakeChallengeReward(s *Session, payload []byte) error { var req goproto.TakeChallengeRewardCsReq if err := proto.Unmarshal(payload, &req); err != nil { return err } return s.Send(&goproto.TakeChallengeRewardScRsp{GroupId: req.GroupId}) } // Return saved teams and measured scores, including genuinely unplayed stages. func buildGetChallengeTierceDataScRspReal(s *Session) *goproto.GetChallengeTierceDataScRsp { rsp := &goproto.GetChallengeTierceDataScRsp{} for _, id := range sortedU32Keys2(getChallengeData().Tierce) { var saved *common.TierceProgress if p := s.JSON(); p != nil { saved = p.TierceProgress[id] } rsp.ChallengeTierceGroups = append(rsp.ChallengeTierceGroups, buildTierceGroup(id, saved)) } return rsp }