From 458d8da802751b39777d63266d22d628504a1956 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Sat, 27 Dec 2025 19:13:24 +0900 Subject: [PATCH 1/9] Change data schema Applying SchaleDB change --- internal/types/schaledb_students.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/types/schaledb_students.go b/internal/types/schaledb_students.go index 5390703..2bcb447 100644 --- a/internal/types/schaledb_students.go +++ b/internal/types/schaledb_students.go @@ -102,11 +102,11 @@ type SkillEffect struct { Hits []int `json:"Hits,omitempty"` ExtraStatRate []int `json:"ExtraStatRate,omitempty"` ExtraStatSource string `json:"ExtraStatSource,omitempty"` - HpRateDamageModifier *HpRateDamageModifier `json:"HpRateDamageModifier,omitempty"` + TargetHpRateModifier *TargetHpRateModifier `json:"TargetHpRateModifier,omitempty"` } // HP-based damage modifier -type HpRateDamageModifier struct { +type TargetHpRateModifier struct { MaxHpRate int `json:"MaxHpRate"` MinHpRate int `json:"MinHpRate"` MultiplierMax float64 `json:"MultiplierMax"` From 7d5e887a49a2d9c4e14d32635fdbca89b57cb959 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Sat, 27 Dec 2025 21:32:06 +0900 Subject: [PATCH 2/9] Add platinum limit to lunatic/non-lunatic filter --- internal/logic/filter/filter.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/logic/filter/filter.go b/internal/logic/filter/filter.go index ae320ec..069281d 100644 --- a/internal/logic/filter/filter.go +++ b/internal/logic/filter/filter.go @@ -51,8 +51,11 @@ func createVideoFilterFromPartyTeams(partyTeams [][6]int) *types.BATormentFilter func CreateLunaticFilter(partyData *types.BATormentPartyData) *types.BATormentFilter { var partyTeams [][6]int - // Filter parties with score >= LunaticMinScore + // Filter parties with score >= LunaticMinScore and rank <= 20000 for _, party := range partyData.PartyDetail { + if party.Rank > 20000 { + continue + } if party.Score >= constants.LunaticMinScore { partyTeams = append(partyTeams, party.PartyData...) } @@ -66,7 +69,11 @@ func CreateNonLunaticFilter(partyData *types.BATormentPartyData) *types.BATormen isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore + // Filter parties with score in range and rank <= 20000 for _, party := range partyData.PartyDetail { + if party.Rank > 20000 { + continue + } maxScore := constants.LunaticMinScore minScore := constants.TormentMinScore if isInsane { From cbccd357c10fa3c2f47f13b397899cb944b3c3f4 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Sat, 27 Dec 2025 21:37:51 +0900 Subject: [PATCH 3/9] Add platinum limit to summary data processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/logic/parse/summary.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/logic/parse/summary.go b/internal/logic/parse/summary.go index ba22f80..6bcc3ab 100644 --- a/internal/logic/parse/summary.go +++ b/internal/logic/parse/summary.go @@ -17,6 +17,9 @@ func ProcessPartyDataToSummaryData(partyData *types.BATormentPartyData) (*types. isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore for _, data := range partyData.PartyDetail { + if data.Rank > 20000 { + continue + } if data.Score >= constants.LunaticMinScore { lunaticData = append(lunaticData, data) } else { From dacb12c7d40319c74a717d3dad61fc2baec057e4 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Sun, 28 Dec 2025 20:33:29 +0900 Subject: [PATCH 4/9] feat: Add total analysis feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Analyze all raids for character usage statistics - STRIKER/SPECIAL/Assist TOP usage per raid - Character usage history across raids (sorted by start_date) - Star distribution for characters with >= 200 usage - Co-usage synergy analysis (>= 5% threshold) - Lunatic clear count and usage tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/total_analysis/main.go | 73 +++++++ docker-entrypoint.sh | 15 +- internal/db/postgres/contents.sql.go | 29 +++ internal/db/postgres/querier.go | 1 + internal/db/postgres/sql/query/contents.sql | 3 + internal/logic/analysis/analysis.go | 91 ++++++++ internal/logic/analysis/character_analysis.go | 205 ++++++++++++++++++ internal/logic/analysis/helper.go | 95 ++++++++ internal/logic/analysis/raid_analysis.go | 69 ++++++ internal/types/analysis_types.go | 60 +++++ 10 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 cmd/total_analysis/main.go create mode 100644 internal/logic/analysis/analysis.go create mode 100644 internal/logic/analysis/character_analysis.go create mode 100644 internal/logic/analysis/helper.go create mode 100644 internal/logic/analysis/raid_analysis.go create mode 100644 internal/types/analysis_types.go diff --git a/cmd/total_analysis/main.go b/cmd/total_analysis/main.go new file mode 100644 index 0000000..f447ae1 --- /dev/null +++ b/cmd/total_analysis/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + + "ba-torment-data-process/internal/db/postgres" + "ba-torment-data-process/internal/logic" + "ba-torment-data-process/internal/logic/analysis" + logic_upload "ba-torment-data-process/internal/logic/upload" + + "github.com/joho/godotenv" +) + +func main() { + if logic.IsLocalEnv() { + if err := godotenv.Load(); err != nil { + log.Fatalf("Failed to load .env file: %v", err) + } + } + + dryRun := flag.Bool("dry-run", false, "Run in dry-run mode (no actual uploads)") + flag.Parse() + + // Initialize database connection + pool := postgres.InitFromEnv() + defer pool.Close() + + queries := postgres.New(pool) + + // Get all content IDs sorted by start_date + contents, err := queries.ListContentIDsWithStartDate(context.Background()) + if err != nil { + log.Fatal(fmt.Errorf("failed to list content IDs: %w", err)) + } + + // Extract content IDs in order + contentIDs := make([]string, len(contents)) + for i, c := range contents { + contentIDs[i] = c.ContentID + } + + log.Printf("Found %d content IDs", len(contentIDs)) + + // Download all party data + log.Println("Downloading party data from S3...") + partyDataMap := analysis.DownloadAllPartyData(contentIDs) + log.Printf("Successfully downloaded %d/%d party data", len(partyDataMap), len(contentIDs)) + + if len(partyDataMap) == 0 { + log.Fatal("No party data available for analysis") + } + + // Run analysis + log.Println("Running total analysis...") + result := analysis.RunTotalAnalysis(partyDataMap, contentIDs) + + // Upload result + err = logic_upload.MarshalAndUpload( + result, + "batorment/v3/total-analysis", + "analysis.json", + *dryRun, + "Total analysis completed", + ) + if err != nil { + log.Fatalf("Failed to upload analysis result: %v", err) + } + + log.Println("Total analysis completed successfully!") +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index cef223f..b92be4b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,7 +7,7 @@ echo "============================================" echo "" # Step 1: Update SchaleDB data -echo "[1/2] Updating student data from SchaleDB..." +echo "[1/3] Updating student data from SchaleDB..." echo "--------------------------------------------" /app/bin/update_from_schaledb if [ $? -eq 0 ]; then @@ -18,7 +18,7 @@ else fi echo "" -echo "[2/2] Processing raid data..." +echo "[2/3] Processing raid data..." echo "--------------------------------------------" /app/bin/process_raid if [ $? -eq 0 ]; then @@ -28,6 +28,17 @@ else exit 1 fi +echo "" +echo "[3/3] Running total analysis..." +echo "--------------------------------------------" +/app/bin/total_analysis +if [ $? -eq 0 ]; then + echo "✓ Total analysis completed successfully" +else + echo "✗ Total analysis failed with exit code $?" + exit 1 +fi + echo "" echo "============================================" echo "All tasks completed successfully!" diff --git a/internal/db/postgres/contents.sql.go b/internal/db/postgres/contents.sql.go index 2395be2..f91acd1 100644 --- a/internal/db/postgres/contents.sql.go +++ b/internal/db/postgres/contents.sql.go @@ -50,3 +50,32 @@ func (q *Queries) ListContentIDs(ctx context.Context) ([]string, error) { } return items, nil } + +const listContentIDsWithStartDate = `-- name: ListContentIDsWithStartDate :many +SELECT content_id, start_date FROM batorment_v3.contents WHERE deleted_at IS NULL ORDER BY start_date ASC +` + +type ListContentIDsWithStartDateRow struct { + ContentID string `json:"content_id"` + StartDate pgtype.Timestamptz `json:"start_date"` +} + +func (q *Queries) ListContentIDsWithStartDate(ctx context.Context) ([]ListContentIDsWithStartDateRow, error) { + rows, err := q.db.Query(ctx, listContentIDsWithStartDate) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListContentIDsWithStartDateRow{} + for rows.Next() { + var i ListContentIDsWithStartDateRow + if err := rows.Scan(&i.ContentID, &i.StartDate); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/postgres/querier.go b/internal/db/postgres/querier.go index ddc204f..b13845d 100644 --- a/internal/db/postgres/querier.go +++ b/internal/db/postgres/querier.go @@ -14,6 +14,7 @@ type Querier interface { InsertPresent(ctx context.Context, arg InsertPresentParams) error InsertStudentData(ctx context.Context, arg InsertStudentDataParams) error ListContentIDs(ctx context.Context) ([]string, error) + ListContentIDsWithStartDate(ctx context.Context) ([]ListContentIDsWithStartDateRow, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/db/postgres/sql/query/contents.sql b/internal/db/postgres/sql/query/contents.sql index ed1d9fe..39584f1 100644 --- a/internal/db/postgres/sql/query/contents.sql +++ b/internal/db/postgres/sql/query/contents.sql @@ -3,3 +3,6 @@ SELECT content_id, start_date FROM batorment_v3.contents WHERE content_id = $1; -- name: ListContentIDs :many SELECT content_id FROM batorment_v3.contents WHERE deleted_at IS NULL; + +-- name: ListContentIDsWithStartDate :many +SELECT content_id, start_date FROM batorment_v3.contents WHERE deleted_at IS NULL ORDER BY start_date ASC; diff --git a/internal/logic/analysis/analysis.go b/internal/logic/analysis/analysis.go new file mode 100644 index 0000000..98046b6 --- /dev/null +++ b/internal/logic/analysis/analysis.go @@ -0,0 +1,91 @@ +package analysis + +import ( + "encoding/json" + "io" + "log" + "net/http" + "time" + + "ba-torment-data-process/internal/types" +) + +const PartyDataBaseURL = "https://twauaebyyujvvvusbrwe.supabase.co/storage/v1/object/public/pb7h4uvn2b6m0lyu7i6r3j8ac/batorment/v3/party" + +// DownloadAllPartyData downloads party data for all content IDs +func DownloadAllPartyData(contentIDs []string) map[string]*types.BATormentPartyData { + result := make(map[string]*types.BATormentPartyData) + + for _, contentID := range contentIDs { + url := PartyDataBaseURL + "/" + contentID + ".json" + data, err := fetchPartyData(url) + if err != nil { + log.Printf("Failed to fetch party data for %s: %v", contentID, err) + continue + } + + var partyData types.BATormentPartyData + if err := json.Unmarshal(data, &partyData); err != nil { + log.Printf("Failed to parse party data for %s: %v", contentID, err) + continue + } + + result[contentID] = &partyData + log.Printf("Downloaded party data for %s: %d parties", contentID, len(partyData.PartyDetail)) + } + + return result +} + +// fetchPartyData fetches party data from URL (non-fatal on error) +func fetchPartyData(url string) ([]byte, error) { + start := time.Now() + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, &httpError{StatusCode: resp.StatusCode, URL: url} + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + log.Printf("Fetched: url=%s, duration=%s", url, time.Since(start)) + return body, nil +} + +type httpError struct { + StatusCode int + URL string +} + +func (e *httpError) Error() string { + return "HTTP " + string(rune(e.StatusCode)) + " for " + e.URL +} + +// RunTotalAnalysis runs the complete analysis +// sortedContentIDs provides the order for raidAnalyses (sorted by start_date) +func RunTotalAnalysis(partyDataMap map[string]*types.BATormentPartyData, sortedContentIDs []string) *types.TotalAnalysisOutput { + log.Printf("Starting total analysis with %d raids", len(partyDataMap)) + + // Raid analysis + log.Println("Running raid analyses...") + raidAnalyses := RunRaidAnalyses(partyDataMap, sortedContentIDs) + log.Printf("Completed raid analyses: %d raids", len(raidAnalyses)) + + // Character analysis + log.Println("Running character analyses...") + characterAnalyses := RunCharacterAnalyses(partyDataMap, sortedContentIDs) + log.Printf("Completed character analyses: %d characters", len(characterAnalyses)) + + return &types.TotalAnalysisOutput{ + GeneratedAt: time.Now().Format(time.RFC3339), + RaidAnalyses: raidAnalyses, + CharacterAnalyses: characterAnalyses, + } +} diff --git a/internal/logic/analysis/character_analysis.go b/internal/logic/analysis/character_analysis.go new file mode 100644 index 0000000..833205c --- /dev/null +++ b/internal/logic/analysis/character_analysis.go @@ -0,0 +1,205 @@ +package analysis + +import ( + "fmt" + "sort" + + "ba-torment-data-process/internal/constants" + "ba-torment-data-process/internal/types" +) + +// RunCharacterAnalyses runs analysis for all characters +// sortedContentIDs provides the order for usageHistory (sorted by start_date) +func RunCharacterAnalyses(partyDataMap map[string]*types.BATormentPartyData, sortedContentIDs []string) []types.CharacterAnalysisResult { + allStudentIDs := collectAllStudentIDs(partyDataMap) + + var results []types.CharacterAnalysisResult + for studentID := range allStudentIDs { + result := AnalyzeCharacter(studentID, partyDataMap, sortedContentIDs) + results = append(results, result) + } + + // Sort by studentID + sort.Slice(results, func(i, j int) bool { + return results[i].StudentID < results[j].StudentID + }) + + return results +} + +// AnalyzeCharacter analyzes a single character across all raids +// sortedContentIDs provides the order for usageHistory (sorted by start_date) +func AnalyzeCharacter(studentID int, partyDataMap map[string]*types.BATormentPartyData, sortedContentIDs []string) types.CharacterAnalysisResult { + var usageHistory []types.RaidUsage + var starDistribution []types.RaidStarDistribution + + totalAsAssist := 0 + totalAsOwn := 0 + + // Synergy calculation: count of co-appearances with other characters + coUsageCount := make(map[int]int) + totalAppearances := 0 + + for _, raidID := range sortedContentIDs { + partyData, ok := partyDataMap[raidID] + if !ok { + continue + } + raidUsage, raidStar, asAssist, asOwn, coChars, appearances := analyzeCharacterInRaid(studentID, partyData) + + usageHistory = append(usageHistory, types.RaidUsage{ + RaidID: raidID, + UserCount: raidUsage.UserCount, + LunaticUserCount: raidUsage.LunaticUserCount, + }) + + // Only include star distribution if own usage (excluding assist) >= 200 (1%) + if asOwn >= 200 { + starDistribution = append(starDistribution, types.RaidStarDistribution{ + RaidID: raidID, + Distribution: raidStar, + }) + } + + totalAsAssist += asAssist + totalAsOwn += asOwn + + for coCharID, count := range coChars { + coUsageCount[coCharID] += count + } + totalAppearances += appearances + } + + // Calculate synergy (top 3, >= 5%) + topSynergyChars := calculateTopSynergy(coUsageCount, totalAppearances, 3) + + totalCount := totalAsAssist + totalAsOwn + assistRatio := 0.0 + if totalCount > 0 { + assistRatio = float64(totalAsAssist) / float64(totalCount) + } + + return types.CharacterAnalysisResult{ + StudentID: studentID, + UsageHistory: usageHistory, + StarDistribution: starDistribution, + AssistStats: types.AssistUsageStats{ + AsAssistCount: totalAsAssist, + AsOwnCount: totalAsOwn, + TotalCount: totalCount, + AssistRatio: assistRatio, + }, + TopSynergyChars: topSynergyChars, + } +} + +// analyzeCharacterInRaid analyzes a character in a single raid +func analyzeCharacterInRaid(studentID int, partyData *types.BATormentPartyData) ( + usage types.RaidUsage, + starDist map[string]int, + asAssist int, + asOwn int, + coChars map[int]int, + appearances int, +) { + starDist = make(map[string]int) + coChars = make(map[int]int) + lunaticUserCount := 0 + + for _, party := range partyData.PartyDetail { + if party.Rank > PlatinumRankLimit { + break + } + + isLunatic := party.Score >= constants.LunaticMinScore + foundInParty := false + var partyMembers []int + + for _, members := range party.PartyData { + for _, member := range members { + if member == 0 { + continue + } + + memberStudentID, star, weaponStar, isAssist := ParseStudentDetailID(member) + + // Collect party members for synergy + partyMembers = append(partyMembers, memberStudentID) + + if memberStudentID == studentID { + foundInParty = true + + if isAssist { + asAssist++ + if isLunatic { + lunaticUserCount++ + } + } else { + asOwn++ + if isLunatic { + lunaticUserCount++ + } + // Star distribution (exclude assist) + key := fmt.Sprintf("%d%d", star, weaponStar) + starDist[key]++ + } + } + } + } + + if foundInParty { + appearances++ + // Count co-usage characters + for _, memberID := range partyMembers { + if memberID != studentID { + coChars[memberID]++ + } + } + } + } + + usage = types.RaidUsage{ + UserCount: asAssist + asOwn, + LunaticUserCount: lunaticUserCount, + } + + return +} + +// calculateTopSynergy calculates top N synergy characters (>= 5% only) +func calculateTopSynergy(coUsageCount map[int]int, totalAppearances int, n int) []types.CharacterSynergy { + if totalAppearances == 0 { + return nil + } + + type kv struct { + Key int + Value int + } + + var candidates []kv + for k, v := range coUsageCount { + rate := float64(v) / float64(totalAppearances) + if rate >= 0.05 { // 5% threshold + candidates = append(candidates, kv{k, v}) + } + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].Value != candidates[j].Value { + return candidates[i].Value > candidates[j].Value + } + return candidates[i].Key < candidates[j].Key + }) + + var result []types.CharacterSynergy + for i := 0; i < n && i < len(candidates); i++ { + result = append(result, types.CharacterSynergy{ + StudentID: candidates[i].Key, + CoUsageRate: float64(candidates[i].Value) / float64(totalAppearances), + CoUsageCount: candidates[i].Value, + }) + } + + return result +} diff --git a/internal/logic/analysis/helper.go b/internal/logic/analysis/helper.go new file mode 100644 index 0000000..6d9a88a --- /dev/null +++ b/internal/logic/analysis/helper.go @@ -0,0 +1,95 @@ +package analysis + +import ( + "sort" + + "ba-torment-data-process/internal/types" +) + +const PlatinumRankLimit = 20000 + +// ParseStudentDetailID extracts studentID, star, weaponStar, isAssist from detailID +// Format: {studentID(5)}{star(1)}{weaponStar(1)}{isAssist(1)} +func ParseStudentDetailID(detailID int) (studentID int, star int, weaponStar int, isAssist bool) { + studentID = detailID / 1000 + star = (detailID % 1000) / 100 + weaponStar = (detailID % 100) / 10 + isAssist = detailID%10 == 1 + return +} + +// IsStriker checks if studentID is a striker (1xxxx) +func IsStriker(studentID int) bool { + return studentID/10000 == 1 +} + +// IsSpecial checks if studentID is a special (2xxxx) +func IsSpecial(studentID int) bool { + return studentID/10000 == 2 +} + +// collectAllStudentIDs collects all unique studentIDs from party data +func collectAllStudentIDs(partyDataMap map[string]*types.BATormentPartyData) map[int]bool { + result := make(map[int]bool) + + for _, partyData := range partyDataMap { + for _, party := range partyData.PartyDetail { + if party.Rank > PlatinumRankLimit { + break + } + for _, members := range party.PartyData { + for _, member := range members { + if member == 0 { + continue + } + studentID := member / 1000 + result[studentID] = true + } + } + } + } + + return result +} + +// getPlatinumUserCount returns the count of platinum users (rank <= 20000) +func getPlatinumUserCount(partyData *types.BATormentPartyData) int { + count := 0 + for _, party := range partyData.PartyDetail { + if party.Rank > PlatinumRankLimit { + break + } + count++ + } + return count +} + +// getTopN extracts top N characters by usage count +func getTopN(countMap map[int]int, n int) []types.CharacterUsage { + type kv struct { + Key int + Value int + } + + var sorted []kv + for k, v := range countMap { + sorted = append(sorted, kv{k, v}) + } + + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Value != sorted[j].Value { + return sorted[i].Value > sorted[j].Value + } + return sorted[i].Key < sorted[j].Key + }) + + var result []types.CharacterUsage + for i := 0; i < n && i < len(sorted); i++ { + result = append(result, types.CharacterUsage{ + StudentID: sorted[i].Key, + UsageCount: sorted[i].Value, + }) + } + + return result +} diff --git a/internal/logic/analysis/raid_analysis.go b/internal/logic/analysis/raid_analysis.go new file mode 100644 index 0000000..42788c3 --- /dev/null +++ b/internal/logic/analysis/raid_analysis.go @@ -0,0 +1,69 @@ +package analysis + +import ( + "ba-torment-data-process/internal/constants" + "ba-torment-data-process/internal/types" +) + +// RunRaidAnalyses runs analysis for all raids +// sortedContentIDs provides the order (sorted by start_date) +func RunRaidAnalyses(partyDataMap map[string]*types.BATormentPartyData, sortedContentIDs []string) []types.RaidAnalysisResult { + var results []types.RaidAnalysisResult + + for _, raidID := range sortedContentIDs { + partyData, ok := partyDataMap[raidID] + if !ok { + continue + } + result := AnalyzeRaid(raidID, partyData) + results = append(results, result) + } + + return results +} + +// AnalyzeRaid analyzes a single raid +func AnalyzeRaid(raidID string, partyData *types.BATormentPartyData) types.RaidAnalysisResult { + strikerCount := make(map[int]int) + specialCount := make(map[int]int) + assistCount := make(map[int]int) + lunaticClearCount := 0 + + for _, party := range partyData.PartyDetail { + if party.Rank > PlatinumRankLimit { + break + } + + if party.Score >= constants.LunaticMinScore { + lunaticClearCount++ + } + + for _, members := range party.PartyData { + for _, member := range members { + if member == 0 { + continue + } + + studentID, _, _, isAssist := ParseStudentDetailID(member) + + if IsStriker(studentID) { + strikerCount[studentID]++ + } else if IsSpecial(studentID) { + specialCount[studentID]++ + } + + if isAssist { + assistCount[studentID]++ + } + } + } + } + + return types.RaidAnalysisResult{ + RaidID: raidID, + TopStrikers: getTopN(strikerCount, 5), + TopSpecials: getTopN(specialCount, 5), + TopAssists: getTopN(assistCount, 3), + LunaticClearCount: lunaticClearCount, + } +} diff --git a/internal/types/analysis_types.go b/internal/types/analysis_types.go new file mode 100644 index 0000000..c08920a --- /dev/null +++ b/internal/types/analysis_types.go @@ -0,0 +1,60 @@ +package types + +// CharacterUsage represents character usage statistics +type CharacterUsage struct { + StudentID int `json:"studentId"` + UsageCount int `json:"usageCount"` +} + +// RaidAnalysisResult represents analysis result for a single raid +type RaidAnalysisResult struct { + RaidID string `json:"raidId"` + TopStrikers []CharacterUsage `json:"topStrikers"` + TopSpecials []CharacterUsage `json:"topSpecials"` + TopAssists []CharacterUsage `json:"topAssists"` + LunaticClearCount int `json:"lunaticClearCount"` +} + +// RaidUsage represents usage statistics for a character in a specific raid +type RaidUsage struct { + RaidID string `json:"raidId"` + UserCount int `json:"userCount"` + LunaticUserCount int `json:"lunaticUserCount"` +} + +// RaidStarDistribution represents star distribution for a character in a specific raid +type RaidStarDistribution struct { + RaidID string `json:"raidId"` + Distribution map[string]int `json:"distribution"` // "star_weaponStar" -> count +} + +// AssistUsageStats represents assist vs own usage statistics +type AssistUsageStats struct { + AsAssistCount int `json:"asAssistCount"` + AsOwnCount int `json:"asOwnCount"` + TotalCount int `json:"totalCount"` + AssistRatio float64 `json:"assistRatio"` +} + +// CharacterSynergy represents co-usage statistics with another character +type CharacterSynergy struct { + StudentID int `json:"studentId"` + CoUsageRate float64 `json:"coUsageRate"` + CoUsageCount int `json:"coUsageCount"` +} + +// CharacterAnalysisResult represents analysis result for a single character +type CharacterAnalysisResult struct { + StudentID int `json:"studentId"` + UsageHistory []RaidUsage `json:"usageHistory"` + StarDistribution []RaidStarDistribution `json:"starDistribution"` + AssistStats AssistUsageStats `json:"assistStats"` + TopSynergyChars []CharacterSynergy `json:"topSynergyChars"` +} + +// TotalAnalysisOutput represents the final output of total analysis +type TotalAnalysisOutput struct { + GeneratedAt string `json:"generatedAt"` + RaidAnalyses []RaidAnalysisResult `json:"raidAnalyses"` + CharacterAnalyses []CharacterAnalysisResult `json:"characterAnalyses"` +} From 3cd8913ea6c23efc33cc8f934eeeea27ae557776 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Sun, 28 Dec 2025 20:39:46 +0900 Subject: [PATCH 5/9] Add raids.json generation to process_raid --- cmd/process_raid/main.go | 37 +++++++++++++++++++-- internal/db/postgres/contents.sql.go | 30 +++++++++++++++++ internal/db/postgres/querier.go | 1 + internal/db/postgres/sql/query/contents.sql | 3 ++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/cmd/process_raid/main.go b/cmd/process_raid/main.go index 620ca8b..d6c9184 100644 --- a/cmd/process_raid/main.go +++ b/cmd/process_raid/main.go @@ -17,6 +17,14 @@ import ( "github.com/joho/godotenv" ) +// RaidListItem represents an item in raids.json +type RaidListItem struct { + ID string `json:"id"` + Name string `json:"name"` + TopLevel string `json:"top_level"` + PartyUpdated bool `json:"party_updated"` +} + func main() { if logic.IsLocalEnv() { if err := godotenv.Load(); err != nil { @@ -33,12 +41,17 @@ func main() { queries := postgres.New(pool) - contentIDs, err := queries.ListContentIDs(context.Background()) + // Get all contents for raid list + contents, err := queries.ListContentsForRaidList(context.Background()) if err != nil { - log.Fatal(fmt.Errorf("failed to list content IDs: %w", err)) + log.Fatal(fmt.Errorf("failed to list contents: %w", err)) } - for _, contentID := range contentIDs { + // Track party_updated status for each content + partyUpdated := make(map[string]bool) + + for _, content := range contents { + contentID := content.ContentID log.Printf("\n=== Processing content: %s ===", contentID) contentInfo, err := queries.GetContentByID(context.Background(), contentID) @@ -51,8 +64,10 @@ func main() { partyData, filterResult, err := logic_duckdb.ParseDuckDB(contentID, contentInfo.StartDate.Time) if err != nil { log.Printf("Skipping content %s: %v", contentID, err) + partyUpdated[contentID] = false continue } + partyUpdated[contentID] = true fileName := fmt.Sprintf("%s.json", contentID) @@ -175,5 +190,21 @@ func main() { log.Printf("✓ Successfully processed content: %s\n", contentID) } + // Generate raids.json + log.Println("\n=== Generating raids.json ===") + var raidList []RaidListItem + for _, content := range contents { + raidList = append(raidList, RaidListItem{ + ID: content.ContentID, + Name: content.Title, + TopLevel: string(content.TopLevel), + PartyUpdated: partyUpdated[content.ContentID], + }) + } + + if err := logic_upload.MarshalAndUpload(raidList, "batorment/v3", "raids.json", *dryRun, "Raids list uploaded"); err != nil { + log.Printf("Failed to upload raids.json: %v", err) + } + fmt.Println("\n=== Successfully processed all raids ===") } diff --git a/internal/db/postgres/contents.sql.go b/internal/db/postgres/contents.sql.go index f91acd1..00f673d 100644 --- a/internal/db/postgres/contents.sql.go +++ b/internal/db/postgres/contents.sql.go @@ -79,3 +79,33 @@ func (q *Queries) ListContentIDsWithStartDate(ctx context.Context) ([]ListConten } return items, nil } + +const listContentsForRaidList = `-- name: ListContentsForRaidList :many +SELECT content_id, title, top_level FROM batorment_v3.contents WHERE deleted_at IS NULL ORDER BY start_date ASC +` + +type ListContentsForRaidListRow struct { + ContentID string `json:"content_id"` + Title string `json:"title"` + TopLevel TopLevel `json:"top_level"` +} + +func (q *Queries) ListContentsForRaidList(ctx context.Context) ([]ListContentsForRaidListRow, error) { + rows, err := q.db.Query(ctx, listContentsForRaidList) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListContentsForRaidListRow{} + for rows.Next() { + var i ListContentsForRaidListRow + if err := rows.Scan(&i.ContentID, &i.Title, &i.TopLevel); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/postgres/querier.go b/internal/db/postgres/querier.go index b13845d..853f61c 100644 --- a/internal/db/postgres/querier.go +++ b/internal/db/postgres/querier.go @@ -15,6 +15,7 @@ type Querier interface { InsertStudentData(ctx context.Context, arg InsertStudentDataParams) error ListContentIDs(ctx context.Context) ([]string, error) ListContentIDsWithStartDate(ctx context.Context) ([]ListContentIDsWithStartDateRow, error) + ListContentsForRaidList(ctx context.Context) ([]ListContentsForRaidListRow, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/db/postgres/sql/query/contents.sql b/internal/db/postgres/sql/query/contents.sql index 39584f1..dd61500 100644 --- a/internal/db/postgres/sql/query/contents.sql +++ b/internal/db/postgres/sql/query/contents.sql @@ -6,3 +6,6 @@ SELECT content_id FROM batorment_v3.contents WHERE deleted_at IS NULL; -- name: ListContentIDsWithStartDate :many SELECT content_id, start_date FROM batorment_v3.contents WHERE deleted_at IS NULL ORDER BY start_date ASC; + +-- name: ListContentsForRaidList :many +SELECT content_id, title, top_level FROM batorment_v3.contents WHERE deleted_at IS NULL ORDER BY start_date ASC; From 7fcbf2de464257ddf36182b226412cb48e50b722 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Mon, 29 Dec 2025 01:33:19 +0900 Subject: [PATCH 6/9] Update image version --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index fe35819..f625825 100644 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ -tag=v0.1.1 +tag=v0.1.2 docker build -t ghcr.io/beaverhouse/ba-data-process:$tag . docker push ghcr.io/beaverhouse/ba-data-process:$tag From 0551e4e07e3570dcccc98df41eda7b89147e8abc Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Mon, 29 Dec 2025 16:21:39 +0900 Subject: [PATCH 7/9] Remove fraud user data from S80-0 rank 13622 --- internal/logic/duckdb/parse.go | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/internal/logic/duckdb/parse.go b/internal/logic/duckdb/parse.go index 35f00d9..bb97590 100644 --- a/internal/logic/duckdb/parse.go +++ b/internal/logic/duckdb/parse.go @@ -125,9 +125,59 @@ func ParseDuckDB(contentID string, startDate time.Time) (*types.BATormentPartyDa return nil, nil, fmt.Errorf("no details found for armor type: %s", armorType) } + // Apply fraud user cleanup for specific content + removeFraudUsers(contentID, partyData) + return partyData, filterResult, nil } +// removeFraudUsers removes known fraudulent user data and adjusts ranks +// S80-0 rank 13622: User with Mika (10059) star 1, UE3 (studentDetailID contains 10059130) +func removeFraudUsers(contentID string, partyData *types.BATormentPartyData) { + if contentID != "S80-0" { + return + } + + // Find the fraud user at rank 13622 with Mika star 1 UE3 (10059130) + fraudIndex := -1 + for i, party := range partyData.PartyDetail { + if party.Rank == 13622 { + // Check if this user has Mika star 1 UE3 (studentDetailID 10059130) + hasFraudChar := false + for _, members := range party.PartyData { + for _, member := range members { + if member == 10059130 { + hasFraudChar = true + break + } + } + if hasFraudChar { + break + } + } + if hasFraudChar { + fraudIndex = i + log.Printf("Found fraud user at rank 13622 with Mika star1 UE3 in S80-0") + } + break + } + } + + if fraudIndex == -1 { + return + } + + // Remove the fraud entry + partyData.PartyDetail = append(partyData.PartyDetail[:fraudIndex], partyData.PartyDetail[fraudIndex+1:]...) + + // Shift ranks for all entries after the removed one (13623+ becomes 13622+) + for i := fraudIndex; i < len(partyData.PartyDetail); i++ { + partyData.PartyDetail[i].Rank-- + } + + log.Printf("Removed fraud user and adjusted %d ranks", len(partyData.PartyDetail)-fraudIndex) +} + func processArmorType(db *sql.DB, armorType string) (*types.BATormentPartyData, *types.BATormentFilter, error) { // Step 1: Get complete run IDs and scores completeRunsSQL := GetCompleteRunIDAndScoreSQL(armorType) From d93ed135d99e6a1f22e731b5a4e1f3ef9489098a Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Mon, 29 Dec 2025 16:22:02 +0900 Subject: [PATCH 8/9] Update image version --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index f625825..3b02717 100644 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ -tag=v0.1.2 +tag=v0.1.3 docker build -t ghcr.io/beaverhouse/ba-data-process:$tag . docker push ghcr.io/beaverhouse/ba-data-process:$tag From fcd297ee8f72d5786cfedfe6c41a409954b18499 Mon Sep 17 00:00:00 2001 From: Austin Lee Date: Mon, 29 Dec 2025 19:48:46 +0900 Subject: [PATCH 9/9] Fix wrong code --- internal/logic/duckdb/parse.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/logic/duckdb/parse.go b/internal/logic/duckdb/parse.go index bb97590..0a2d137 100644 --- a/internal/logic/duckdb/parse.go +++ b/internal/logic/duckdb/parse.go @@ -132,21 +132,21 @@ func ParseDuckDB(contentID string, startDate time.Time) (*types.BATormentPartyDa } // removeFraudUsers removes known fraudulent user data and adjusts ranks -// S80-0 rank 13622: User with Mika (10059) star 1, UE3 (studentDetailID contains 10059130) +// S80-0 rank 13622: User with Mika (10059) star 5, UE1 (studentDetailID contains 10059510) func removeFraudUsers(contentID string, partyData *types.BATormentPartyData) { if contentID != "S80-0" { return } - // Find the fraud user at rank 13622 with Mika star 1 UE3 (10059130) + // Find the fraud user at rank 13622 with Mika star 5 UE1 (studentDetailID 10059510) fraudIndex := -1 for i, party := range partyData.PartyDetail { if party.Rank == 13622 { - // Check if this user has Mika star 1 UE3 (studentDetailID 10059130) + // Check if this user has Mika star 5 UE1 (studentDetailID 10059510) hasFraudChar := false for _, members := range party.PartyData { for _, member := range members { - if member == 10059130 { + if member == 10059510 { hasFraudChar = true break }