package common import ( "encoding/json" "fmt" "slices" "sort" "strconv" ) // MonsterSettingsCatalog is a read-only preview of challenge stages plus the // resource-backed ID/level suggestions used by the Custom Enemy editor. type MonsterSettingsCatalog struct { Modes map[string][]MonsterSettingsGroup `json:"modes"` MonsterIDs []uint32 `json:"monster_ids"` Monsters map[uint32]MonsterSettingsMonsterMetadata `json:"monsters"` Levels []uint32 `json:"levels"` Sources []string `json:"sources"` } type MonsterSettingsGroup struct { ID uint32 `json:"id"` Names map[string]string `json:"names,omitempty"` Stages []MonsterSettingsEncounter `json:"stages"` } type MonsterSettingsEncounter struct { ID uint32 `json:"id"` IsBoss bool `json:"is_boss,omitempty"` DefaultBuffID uint32 `json:"default_buff_id,omitempty"` MonsterIDs []uint32 `json:"monster_ids,omitempty"` Battles []MonsterSettingsBattle `json:"battles"` } type MonsterSettingsMonsterMetadata struct { Names map[string]string `json:"names"` IconURL string `json:"icon_url,omitempty"` } type MonsterSettingsBattle struct { StageID uint32 `json:"stage_id"` Level uint32 `json:"level"` Waves [][]uint32 `json:"waves"` } type monsterSettingStageData struct { Level uint32 `json:"level"` Monsters [][]uint32 `json:"monsters"` } type monsterSettingsAccumulator struct { groups map[uint32]map[uint32]*MonsterSettingsEncounter } // LoadMonsterSettingsCatalog uses the same installed challenge and stage // resources as the game-server battle handlers. It never supplies a battle // override; custom waves are separately restricted to Client Calyx rows. func LoadMonsterSettingsCatalog() (MonsterSettingsCatalog, error) { challengeRaw, err := ReadResourceFile("challenge_data.json") if err != nil { return MonsterSettingsCatalog{}, fmt.Errorf("read challenge catalog: %w", err) } stageRaw, err := ReadResourceFile("resources/StageConfig.json") if err != nil { return MonsterSettingsCatalog{}, fmt.Errorf("read Client stage catalog: %w", err) } var challenges struct { Challenges map[string]struct { GroupID uint32 `json:"groupId"` Monster1 uint32 `json:"monster1"` Monster2 uint32 `json:"monster2"` Event1 uint32 `json:"event1"` Event2 uint32 `json:"event2"` } `json:"challenges"` Tierce map[string]struct { Group uint32 `json:"group"` Event uint32 `json:"event"` } `json:"tierce"` Stages map[string]monsterSettingStageData `json:"stages"` Peak struct { Stages map[string]struct { EventID uint32 `json:"event_id"` HardEventID uint32 `json:"hard_event_id"` MonsterID uint32 `json:"monster_id"` DefaultBuff uint32 `json:"default_buff"` IsBoss bool `json:"is_boss"` } `json:"stages"` Groups map[string]struct { PreLevelIDs []uint32 `json:"pre_level_ids"` BossLevelID uint32 `json:"boss_level_id"` } `json:"groups"` } `json:"peak"` } if err := json.Unmarshal(challengeRaw, &challenges); err != nil { return MonsterSettingsCatalog{}, fmt.Errorf("decode challenge catalog: %w", err) } var stageTable struct { Entries []struct { StageID uint32 `json:"StageID"` Level uint32 `json:"Level"` MonsterList [][]uint32 `json:"MonsterList"` } `json:"stage_config"` } if err := json.Unmarshal(stageRaw, &stageTable); err != nil { return MonsterSettingsCatalog{}, fmt.Errorf("decode Client stage catalog: %w", err) } stageByID := make(map[uint32]monsterSettingStageData, len(stageTable.Entries)) monsterSet := map[uint32]struct{}{} levelSet := map[uint32]struct{}{} for _, row := range stageTable.Entries { stageByID[row.StageID] = monsterSettingStageData{Level: row.Level, Monsters: row.MonsterList} if row.Level != 0 { levelSet[row.Level] = struct{}{} } for _, wave := range row.MonsterList { for _, id := range wave { if id != 0 { monsterSet[id] = struct{}{} } } } } if len(stageByID) == 0 { return MonsterSettingsCatalog{}, fmt.Errorf("Client StageConfig has no stage rows") } acc := map[string]*monsterSettingsAccumulator{ "moc": {groups: map[uint32]map[uint32]*MonsterSettingsEncounter{}}, "pf": {groups: map[uint32]map[uint32]*MonsterSettingsEncounter{}}, "as": {groups: map[uint32]map[uint32]*MonsterSettingsEncounter{}}, "aa": {groups: map[uint32]map[uint32]*MonsterSettingsEncounter{}}, } addBattle := func(entry *MonsterSettingsEncounter, eventID uint32) { stage, ok := stageByID[eventID] if !ok || len(stage.Monsters) == 0 { return } for _, existing := range entry.Battles { if existing.StageID == eventID { return } } entry.Battles = append(entry.Battles, MonsterSettingsBattle{StageID: eventID, Level: stage.Level, Waves: stage.Monsters}) } addEntry := func(mode string, groupID uint32, entryID uint32, monsterIDs []uint32, events ...uint32) { bucket, ok := acc[mode] if !ok { return } if bucket.groups[groupID] == nil { bucket.groups[groupID] = map[uint32]*MonsterSettingsEncounter{} } entry := bucket.groups[groupID][entryID] if entry == nil { entry = &MonsterSettingsEncounter{ID: entryID} bucket.groups[groupID][entryID] = entry } for _, monsterID := range monsterIDs { if monsterID == 0 || slices.Contains(entry.MonsterIDs, monsterID) { continue } entry.MonsterIDs = append(entry.MonsterIDs, monsterID) } for _, eventID := range events { if eventID != 0 { addBattle(entry, eventID) } } } modeForChallengeID := func(id uint32) string { // These are the same challenge-ID namespaces used by // setChallengeBattleConfigWithLimit in the game server. switch { case id >= 30000: return "as" case id >= 20000: return "pf" default: return "moc" } } for rawID, row := range challenges.Challenges { id64, err := strconv.ParseUint(rawID, 10, 32) if err != nil || id64 == 0 { continue } addEntry(modeForChallengeID(uint32(id64)), row.GroupID, uint32(id64), []uint32{row.Monster1, row.Monster2}, row.Event1, row.Event2) } for rawID, row := range challenges.Tierce { id64, err := strconv.ParseUint(rawID, 10, 32) if err != nil || id64 == 0 { continue } addEntry(modeForChallengeID(uint32(id64)), row.Group, uint32(id64), nil, row.Event) } peakGroupForStage := map[uint32]uint32{} for rawGroupID, group := range challenges.Peak.Groups { groupID64, err := strconv.ParseUint(rawGroupID, 10, 32) if err != nil { continue } groupID := uint32(groupID64) for _, stageID := range group.PreLevelIDs { peakGroupForStage[stageID] = groupID } if group.BossLevelID != 0 { peakGroupForStage[group.BossLevelID] = groupID } } for rawStageID, row := range challenges.Peak.Stages { stageID64, err := strconv.ParseUint(rawStageID, 10, 32) if err != nil || stageID64 == 0 { continue } stageID := uint32(stageID64) groupID := peakGroupForStage[stageID] if groupID == 0 { continue } bucket := acc["aa"].groups if bucket[groupID] == nil { bucket[groupID] = map[uint32]*MonsterSettingsEncounter{} } entry := &MonsterSettingsEncounter{ID: stageID, IsBoss: row.IsBoss, DefaultBuffID: row.DefaultBuff, MonsterIDs: []uint32{row.MonsterID}} addBattle(entry, row.EventID) addBattle(entry, row.HardEventID) if len(entry.Battles) != 0 { bucket[groupID][stageID] = entry } } result := MonsterSettingsCatalog{ Modes: map[string][]MonsterSettingsGroup{}, Sources: []string{"challenge_data.json", "resources/StageConfig.json"}, } for _, mode := range []string{"moc", "pf", "as", "aa"} { for groupID, stages := range acc[mode].groups { group := MonsterSettingsGroup{ID: groupID} for _, stage := range stages { if len(stage.Battles) == 0 { continue } sort.Slice(stage.Battles, func(i, j int) bool { return stage.Battles[i].StageID < stage.Battles[j].StageID }) if len(stage.MonsterIDs) == 0 { for _, wave := range stage.Battles[0].Waves { for _, monsterID := range wave { if monsterID != 0 && !slices.Contains(stage.MonsterIDs, monsterID) { stage.MonsterIDs = append(stage.MonsterIDs, monsterID) if len(stage.MonsterIDs) == 2 { break } } } if len(stage.MonsterIDs) == 2 { break } } } group.Stages = append(group.Stages, *stage) } if len(group.Stages) != 0 { sort.Slice(group.Stages, func(i, j int) bool { return group.Stages[i].ID < group.Stages[j].ID }) result.Modes[mode] = append(result.Modes[mode], group) } } sort.Slice(result.Modes[mode], func(i, j int) bool { return result.Modes[mode][i].ID < result.Modes[mode][j].ID }) } for id := range monsterSet { result.MonsterIDs = append(result.MonsterIDs, id) } for level := range levelSet { result.Levels = append(result.Levels, level) } sort.Slice(result.MonsterIDs, func(i, j int) bool { return result.MonsterIDs[i] < result.MonsterIDs[j] }) sort.Slice(result.Levels, func(i, j int) bool { return result.Levels[i] < result.Levels[j] }) return result, nil }