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

539 lines
19 KiB
Go
Raw Normal View History

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.
2026-10-28 18:54:43 +07:00
// Stage compositions and scoring come from the installed challenge resources.
func setChallengeBattleConfig(s *Session, avatars []uint32, challengeID, eventID uint32, buffs []uint32) error {
return setChallengeBattleConfigWithLimit(s, avatars, challengeID, eventID, buffs, "", 0)
}
func setChallengeBattleConfigWithLimit(s *Session, avatars []uint32, challengeID, eventID uint32, buffs []uint32, mode common.BattleType, limit 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
}
if mode != "" {
config.BattleType = mode
}
// Peak IDs occupy a separate namespace from ChallengeMaze IDs. Its
// explicit native limit must never be replaced by a same-numbered MoC row.
config.CycleCount = limit
if mode == "" {
// 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)
2026-10-28 18:54:43 +07:00
// Each Endgame room starts with its own team's full technique capacity.
// The capacity remains resource-derived; do not reuse overworld initial_mp.
lineup.Mp = lineup.MaxMp
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)})
}
2026-10-28 18:54:43 +07:00
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)})
}
2026-10-28 18:54:43 +07:00
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
}