package gameserver import ( "encoding/json" "fmt" "sync" "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" ) type worldEvent struct { EventID uint32 `json:"EventID"` WorldLevel uint32 `json:"WorldLevel"` StageID uint32 `json:"StageID"` } type cocoonResource struct { ID uint32 `json:"ID"` WorldLevel uint32 `json:"WorldLevel"` CocoonType uint32 `json:"CocoonType"` StageID uint32 `json:"StageID"` StageIDs []uint32 `json:"StageIDList"` MaxCount uint32 `json:"MaxChallengeCnt"` } // CocoonType values from the Client's Cocoon table. Only Calyx entries may // use FreeSR custom battles; Stagnant Shadow and Cavern must use Client stages. const ( clientCocoonTypeCalyx uint32 = 1 clientCocoonTypeStagnantShadow uint32 = 2 clientCocoonTypeCavernCorrosion uint32 = 3 ) func canUseSrtoolCustomCocoon(row cocoonResource) bool { return row.CocoonType == clientCocoonTypeCalyx } type worldBattleResources struct { ClientVersion string `json:"client_version"` WorldLevel uint32 `json:"world_level"` Events []worldEvent `json:"events"` Cocoons []cocoonResource `json:"cocoons"` NPCMonsters map[uint32]struct { Rank uint32 `json:"rank"` } `json:"npc_monsters"` InstantDefeatAvatars []uint32 `json:"instant_defeat_avatars"` InstantDefeatRanks []uint32 `json:"instant_defeat_ranks"` } type sceneMonsterIdentity struct { MonsterID uint32 GroupID uint32 } // Match both the technique owner and the actual NPC rank exported from Client. // Challenge bosses and tool-controlled encounters always use their battle flow. func (s *Session) instantDefeatTargets(req *goproto.SceneCastSkillCsReq, targets []uint32) []uint32 { if req.SkillIndex != 1 || s.ChallengeBattleConfig != nil || s.ChallengeStageID != 0 || common.SrtoolCustomBattleActive() { return nil } data, err := loadWorldBattleResources() if err != nil { return nil } owner := s.techniqueAvatar(req.CasterId) allowed := false for _, id := range data.InstantDefeatAvatars { if id == owner { allowed = true break } } if !allowed { return nil } var defeated []uint32 for _, entity := range targets { identity, ok := s.SceneMonsterIdentities[entity] if !ok { continue } row, ok := data.NPCMonsters[identity.MonsterID] if !ok { continue } for _, rank := range data.InstantDefeatRanks { if rank == row.Rank { defeated = append(defeated, entity) break } } } return defeated } func (s *Session) removeSceneMonsters(ids []uint32) error { notify := &goproto.SceneGroupRefreshScNotify{FloorId: s.CurrentSceneFloor} groups := map[uint32]*goproto.SceneGroupRefreshInfo{} for _, id := range ids { m, ok := s.SceneMonsterIdentities[id] if !ok { continue } group := groups[m.GroupID] if group == nil { group = &goproto.SceneGroupRefreshInfo{GroupId: m.GroupID, State: 1, GroupRefreshType: goproto.SceneGroupRefreshType_SCENE_GROUP_REFRESH_TYPE_LOADED} groups[m.GroupID] = group } group.RefreshEntity = append(group.RefreshEntity, &goproto.SceneEntityRefreshInfo{RefreshType: &goproto.SceneEntityRefreshInfo_DeleteEntity{DeleteEntity: id}}) delete(s.MonsterEntities, id) delete(s.SceneMonsterIdentities, id) } for _, id := range sortedU32Keys2(groups) { notify.GroupRefreshInfo = append(notify.GroupRefreshInfo, groups[id]) } if len(notify.GroupRefreshInfo) == 0 { return nil } return s.Send(notify) } var worldBattleOnce sync.Once var worldBattleData worldBattleResources var worldBattleError error func loadWorldBattleResources() (*worldBattleResources, error) { worldBattleOnce.Do(func() { var raw []byte raw, worldBattleError = common.ReadResourceFile("resources/WorldBattleConfig.json") if worldBattleError != nil { return } worldBattleError = json.Unmarshal(raw, &worldBattleData) if worldBattleError != nil { return } cfg, err := common.LoadRuntimeConfig() if err != nil { worldBattleError = err return } if cfg.ClientVersion != worldBattleData.ClientVersion || len(worldBattleData.Events) == 0 || len(worldBattleData.Cocoons) == 0 { worldBattleError = fmt.Errorf("world battle resource missing or Client revision mismatch") } }) return &worldBattleData, worldBattleError } func resourceBattleConfig(stageID uint32) (*common.BattleConfig, error) { stage, ok := getChallengeData().Stages[stageID] if !ok || stage.Level == 0 || len(stage.Monsters) == 0 { return nil, fmt.Errorf("stage %d has no verified waves", stageID) } rules, err := loadBattleRules() if err != nil { return nil, err } config := &common.BattleConfig{StageID: stageID, BattleType: common.BattleTypeDefault, CycleCount: rules.ChallengeRoundLimits[common.BattleTypeDefault]} for _, wave := range stage.Monsters { if len(wave) == 0 { return nil, fmt.Errorf("stage %d has an empty wave", stageID) } var monsters []common.Monster for _, id := range wave { monsters = append(monsters, common.Monster{MonsterID: id, Level: stage.Level}) } config.Monsters = append(config.Monsters, monsters) } return config, nil } func (s *Session) worldEncounterConfig(targets []uint32) (*common.BattleConfig, error) { if s.ChallengeBattleConfig != nil { return s.ChallengeBattleConfig, nil } data, err := loadWorldBattleResources() if err != nil { return nil, err } if len(targets) == 0 { return nil, fmt.Errorf("battle has no scene monster") } var combined *common.BattleConfig seen := map[uint32]bool{} for _, target := range targets { eventID := s.MonsterEntities[target] if seen[eventID] { continue } seen[eventID] = true config, err := s.eventBattleConfig(eventID, data.WorldLevel) if err != nil { return nil, err } if combined == nil { combined = config } else { combined.Monsters = append(combined.Monsters, config.Monsters...) } } return combined, nil } func (s *Session) eventBattleConfig(eventID, worldLevel uint32) (*common.BattleConfig, error) { data, err := loadWorldBattleResources() if err != nil { return nil, err } for _, row := range data.Events { if row.EventID == eventID && row.WorldLevel == worldLevel { config, err := resourceBattleConfig(row.StageID) if config != nil { level := worldLevel config.WorldLevel = &level } return config, err } } return nil, fmt.Errorf("scene event %d world level %d has no Client stage mapping", eventID, worldLevel) } func (s *Session) cocoonBattleConfig(id, worldLevel, wave uint32) (*common.BattleConfig, error) { data, err := loadWorldBattleResources() if err != nil { return nil, err } for _, row := range data.Cocoons { if row.ID != id || row.WorldLevel != worldLevel { continue } if row.MaxCount != 0 && wave > row.MaxCount { return nil, fmt.Errorf("cocoon wave exceeds Client limit") } if common.SrtoolCustomBattleActive() && canUseSrtoolCustomCocoon(row) { // The Client's Cocoon table identifies Calyx separately from // Stagnant Shadow and Cavern of Corrosion. Custom battles are limited // to Calyx; all other cocoon encounters use their Client stage rows. player := s.JSON() if player == nil { return nil, fmt.Errorf("player data is not loaded") } config := player.BattleConfig if len(config.Monsters) != 0 { return &config, nil } // An empty custom wave list means the user selected “use Client // stages” in Srtool. Fall through to the installed Calyx stage rows. } stages := row.StageIDs if len(stages) == 0 && row.StageID != 0 { stages = []uint32{row.StageID} } if len(stages) == 0 { return nil, fmt.Errorf("cocoon %d has no stages", id) } config, err := resourceBattleConfig(stages[0]) if err != nil { return nil, err } level := worldLevel config.WorldLevel = &level // Quick-start sends zero for its default single stage. Explicit counts // repeat complete resource stages; never replace their monster lists. for i := uint32(1); i < wave; i++ { next, err := resourceBattleConfig(stages[int(i)%len(stages)]) if err != nil { return nil, err } config.Monsters = append(config.Monsters, next.Monsters...) } return config, nil } return nil, fmt.Errorf("unknown cocoon %d at world level %d", id, worldLevel) }