diff --git a/build.sh b/build.sh index 3b02717..6590d94 100644 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ -tag=v0.1.3 +tag=v0.1.4 docker build -t ghcr.io/beaverhouse/ba-data-process:$tag . docker push ghcr.io/beaverhouse/ba-data-process:$tag diff --git a/cmd/total_analysis/main.go b/cmd/total_analysis/main.go index f447ae1..80ddc73 100644 --- a/cmd/total_analysis/main.go +++ b/cmd/total_analysis/main.go @@ -60,8 +60,8 @@ func main() { // Upload result err = logic_upload.MarshalAndUpload( result, - "batorment/v3/total-analysis", - "analysis.json", + "batorment/v3", + "total-analysis.json", *dryRun, "Total analysis completed", ) diff --git a/cmd/update_from_schaledb/main.go b/cmd/update_from_schaledb/main.go index bb6a691..1b8cd03 100644 --- a/cmd/update_from_schaledb/main.go +++ b/cmd/update_from_schaledb/main.go @@ -31,5 +31,10 @@ func main() { log.Fatalf("Failed to parse SchaleDB presents: %v", err) } - log.Println("Successfully parsed SchaleDB students") + err = schaledb.SaveI18nData(queries) + if err != nil { + log.Fatalf("Failed to save i18n data: %v", err) + } + + log.Println("Successfully updated from SchaleDB") } diff --git a/env.yaml b/env.yaml index 0b37ee8..af9c2b0 100644 --- a/env.yaml +++ b/env.yaml @@ -9,8 +9,8 @@ env: - secretKey: "POSTGRES_PASSWORD" remoteRef: "SUPABASE_POSTGRES_PASSWORD" # Service key for personal file manager API. - - secretKey: "FILE_MANAGER_SERVICE_API_KEY" - remoteRef: "FILE_MANAGER_SERVICE_API_KEY" + - secretKey: "BA_ANALYZER_SERVICE_TOKEN" + remoteRef: "BA_ANALYZER_SERVICE_TOKEN" # Base URL for DuckDB download. - secretKey: "BATORMENT_DUCKDB_REMOTE_URL" remoteRef: "BATORMENT_DUCKDB_REMOTE_URL" diff --git a/internal/db/postgres/i18n.sql.go b/internal/db/postgres/i18n.sql.go new file mode 100644 index 0000000..da9c41b --- /dev/null +++ b/internal/db/postgres/i18n.sql.go @@ -0,0 +1,95 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: i18n.sql + +package postgres + +import ( + "context" +) + +const getI18n = `-- name: GetI18n :one +SELECT category, key, name_ko, name_ja, name_en, created_at, updated_at FROM batorment_v3.i18n WHERE category = $1 AND key = $2 +` + +type GetI18nParams struct { + Category string `json:"category"` + Key string `json:"key"` +} + +func (q *Queries) GetI18n(ctx context.Context, arg GetI18nParams) (BatormentV3I18n, error) { + row := q.db.QueryRow(ctx, getI18n, arg.Category, arg.Key) + var i BatormentV3I18n + err := row.Scan( + &i.Category, + &i.Key, + &i.NameKo, + &i.NameJa, + &i.NameEn, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getI18nByCategory = `-- name: GetI18nByCategory :many +SELECT category, key, name_ko, name_ja, name_en, created_at, updated_at FROM batorment_v3.i18n WHERE category = $1 +` + +func (q *Queries) GetI18nByCategory(ctx context.Context, category string) ([]BatormentV3I18n, error) { + rows, err := q.db.Query(ctx, getI18nByCategory, category) + if err != nil { + return nil, err + } + defer rows.Close() + items := []BatormentV3I18n{} + for rows.Next() { + var i BatormentV3I18n + if err := rows.Scan( + &i.Category, + &i.Key, + &i.NameKo, + &i.NameJa, + &i.NameEn, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertI18n = `-- name: UpsertI18n :exec +INSERT INTO batorment_v3.i18n (category, key, name_ko, name_ja, name_en, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) +ON CONFLICT (category, key) DO UPDATE SET + name_ko = EXCLUDED.name_ko, + name_ja = EXCLUDED.name_ja, + name_en = EXCLUDED.name_en, + updated_at = NOW() +` + +type UpsertI18nParams struct { + Category string `json:"category"` + Key string `json:"key"` + NameKo string `json:"name_ko"` + NameJa string `json:"name_ja"` + NameEn string `json:"name_en"` +} + +func (q *Queries) UpsertI18n(ctx context.Context, arg UpsertI18nParams) error { + _, err := q.db.Exec(ctx, upsertI18n, + arg.Category, + arg.Key, + arg.NameKo, + arg.NameJa, + arg.NameEn, + ) + return err +} diff --git a/internal/db/postgres/models.go b/internal/db/postgres/models.go index 47fbc7e..46732a3 100644 --- a/internal/db/postgres/models.go +++ b/internal/db/postgres/models.go @@ -108,6 +108,16 @@ type BatormentV3Content struct { DeletedAt pgtype.Timestamptz `json:"deleted_at"` } +type BatormentV3I18n struct { + Category string `json:"category"` + Key string `json:"key"` + NameKo string `json:"name_ko"` + NameJa string `json:"name_ja"` + NameEn string `json:"name_en"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + type BatormentV3Present struct { PresentID int32 `json:"present_id"` NameKo string `json:"name_ko"` diff --git a/internal/db/postgres/querier.go b/internal/db/postgres/querier.go index 853f61c..9f5988b 100644 --- a/internal/db/postgres/querier.go +++ b/internal/db/postgres/querier.go @@ -10,12 +10,15 @@ import ( type Querier interface { GetContentByID(ctx context.Context, contentID string) (GetContentByIDRow, error) + GetI18n(ctx context.Context, arg GetI18nParams) (BatormentV3I18n, error) + GetI18nByCategory(ctx context.Context, category string) ([]BatormentV3I18n, error) GetVerifiedYoutubeAnalysisByRaidID(ctx context.Context, raidID string) ([]GetVerifiedYoutubeAnalysisByRaidIDRow, error) 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) ListContentsForRaidList(ctx context.Context) ([]ListContentsForRaidListRow, error) + UpsertI18n(ctx context.Context, arg UpsertI18nParams) error } var _ Querier = (*Queries)(nil) diff --git a/internal/db/postgres/sql/query/i18n.sql b/internal/db/postgres/sql/query/i18n.sql new file mode 100644 index 0000000..47dd479 --- /dev/null +++ b/internal/db/postgres/sql/query/i18n.sql @@ -0,0 +1,14 @@ +-- name: UpsertI18n :exec +INSERT INTO batorment_v3.i18n (category, key, name_ko, name_ja, name_en, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) +ON CONFLICT (category, key) DO UPDATE SET + name_ko = EXCLUDED.name_ko, + name_ja = EXCLUDED.name_ja, + name_en = EXCLUDED.name_en, + updated_at = NOW(); + +-- name: GetI18nByCategory :many +SELECT * FROM batorment_v3.i18n WHERE category = $1; + +-- name: GetI18n :one +SELECT * FROM batorment_v3.i18n WHERE category = $1 AND key = $2; diff --git a/internal/db/postgres/sql/schema/schema_251007.sql b/internal/db/postgres/sql/schema/schema_251007.sql index 35fe345..f8037d8 100644 --- a/internal/db/postgres/sql/schema/schema_251007.sql +++ b/internal/db/postgres/sql/schema/schema_251007.sql @@ -54,10 +54,23 @@ CREATE TABLE batorment_v3.presents ( updated_at TIMESTAMP ); +-- Table for i18n translations (school, club, etc.) +CREATE TABLE batorment_v3.i18n ( + category VARCHAR(20) NOT NULL, + key VARCHAR(50) NOT NULL, + name_ko VARCHAR(100) NOT NULL, + name_ja VARCHAR(100) NOT NULL, + name_en VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (category, key) +); + -- Indexes for better performance CREATE INDEX IF NOT EXISTS idx_students_name ON batorment_v3.students(name_ko); CREATE INDEX IF NOT EXISTS idx_students_name_ja ON batorment_v3.students(name_ja); CREATE INDEX IF NOT EXISTS idx_students_details ON batorment_v3.students USING GIN (detail); CREATE INDEX IF NOT EXISTS idx_presents_name ON batorment_v3.presents(name_ko); CREATE INDEX IF NOT EXISTS idx_presents_tags ON batorment_v3.presents USING GIN (tags); +CREATE INDEX IF NOT EXISTS idx_i18n_category ON batorment_v3.i18n(category); diff --git a/internal/logic/analysis/analysis.go b/internal/logic/analysis/analysis.go index 98046b6..15f779f 100644 --- a/internal/logic/analysis/analysis.go +++ b/internal/logic/analysis/analysis.go @@ -5,6 +5,7 @@ import ( "io" "log" "net/http" + "sort" "time" "ba-torment-data-process/internal/types" @@ -83,9 +84,56 @@ func RunTotalAnalysis(partyDataMap map[string]*types.BATormentPartyData, sortedC characterAnalyses := RunCharacterAnalyses(partyDataMap, sortedContentIDs) log.Printf("Completed character analyses: %d characters", len(characterAnalyses)) + // Calculate and assign overall rankings + log.Println("Calculating overall rankings...") + AssignOverallRankings(characterAnalyses) + log.Println("Completed overall ranking calculation") + return &types.TotalAnalysisOutput{ GeneratedAt: time.Now().Format(time.RFC3339), RaidAnalyses: raidAnalyses, CharacterAnalyses: characterAnalyses, } } + +// AssignOverallRankings calculates and assigns overall usage rankings to characterAnalyses +func AssignOverallRankings(characterAnalyses []types.CharacterAnalysisResult) { + // Calculate total usage for each character + for i := range characterAnalyses { + totalUsage := 0 + for _, ru := range characterAnalyses[i].UsageHistory { + totalUsage += ru.UserCount + } + characterAnalyses[i].TotalUsage = totalUsage + } + + // Create index slice for sorting + indices := make([]int, len(characterAnalyses)) + for i := range indices { + indices[i] = i + } + + // Sort indices by total usage (descending) + sort.Slice(indices, func(i, j int) bool { + return characterAnalyses[indices[i]].TotalUsage > characterAnalyses[indices[j]].TotalUsage + }) + + // Assign overall ranks based on sorted order + for rank, idx := range indices { + characterAnalyses[idx].OverallRank = rank + 1 + } + + // Calculate category ranks (striker: 1xxxx, special: 2xxxx) + strikerRank := 1 + specialRank := 1 + for _, idx := range indices { + studentID := characterAnalyses[idx].StudentID + if studentID >= 10000 && studentID < 20000 { + characterAnalyses[idx].CategoryRank = strikerRank + strikerRank++ + } else if studentID >= 20000 && studentID < 30000 { + characterAnalyses[idx].CategoryRank = specialRank + specialRank++ + } + } +} diff --git a/internal/logic/analysis/character_analysis.go b/internal/logic/analysis/character_analysis.go index 833205c..b20f720 100644 --- a/internal/logic/analysis/character_analysis.go +++ b/internal/logic/analysis/character_analysis.go @@ -31,7 +31,11 @@ func RunCharacterAnalyses(partyDataMap map[string]*types.BATormentPartyData, sor // 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 + + // Group star distribution by groupID (e.g., "3S26-1", "3S26-3" -> "3S26") + groupStar := make(map[string]map[string]int) + groupAsOwn := make(map[string]int) + var groupOrder []string // Track order of first appearance totalAsAssist := 0 totalAsOwn := 0 @@ -47,19 +51,24 @@ func AnalyzeCharacter(studentID int, partyDataMap map[string]*types.BATormentPar } raidUsage, raidStar, asAssist, asOwn, coChars, appearances := analyzeCharacterInRaid(studentID, partyData) + // usageHistory: individual raid entries (no grouping) 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, - }) + // starDistribution: group by groupID + groupID := ExtractGroupID(raidID) + if _, exists := groupStar[groupID]; !exists { + groupStar[groupID] = make(map[string]int) + groupOrder = append(groupOrder, groupID) + } + + for key, count := range raidStar { + groupStar[groupID][key] += count } + groupAsOwn[groupID] += asOwn totalAsAssist += asAssist totalAsOwn += asOwn @@ -70,6 +79,19 @@ func AnalyzeCharacter(studentID int, partyDataMap map[string]*types.BATormentPar totalAppearances += appearances } + // Get latest star distribution (200+ own usage) + var starDistribution *types.RaidStarDistribution + for i := len(groupOrder) - 1; i >= 0; i-- { + groupID := groupOrder[i] + if groupAsOwn[groupID] >= 200 { + starDistribution = &types.RaidStarDistribution{ + RaidID: groupID, + Distribution: groupStar[groupID], + } + break + } + } + // Calculate synergy (top 3, >= 5%) topSynergyChars := calculateTopSynergy(coUsageCount, totalAppearances, 3) @@ -112,22 +134,25 @@ func analyzeCharacterInRaid(studentID int, partyData *types.BATormentPartyData) } isLunatic := party.Score >= constants.LunaticMinScore - foundInParty := false - var partyMembers []int + foundInAnySquad := false - for _, members := range party.PartyData { - for _, member := range members { + for _, squad := range party.PartyData { + var squadMembers []int + foundInThisSquad := false + + for _, member := range squad { if member == 0 { continue } memberStudentID, star, weaponStar, isAssist := ParseStudentDetailID(member) - // Collect party members for synergy - partyMembers = append(partyMembers, memberStudentID) + // Collect squad members for synergy + squadMembers = append(squadMembers, memberStudentID) if memberStudentID == studentID { - foundInParty = true + foundInThisSquad = true + foundInAnySquad = true if isAssist { asAssist++ @@ -145,17 +170,20 @@ func analyzeCharacterInRaid(studentID int, partyData *types.BATormentPartyData) } } } - } - if foundInParty { - appearances++ - // Count co-usage characters - for _, memberID := range partyMembers { - if memberID != studentID { - coChars[memberID]++ + // Count co-usage only for squads where this character appears + if foundInThisSquad { + for _, memberID := range squadMembers { + if memberID != studentID { + coChars[memberID]++ + } } } } + + if foundInAnySquad { + appearances++ + } } usage = types.RaidUsage{ diff --git a/internal/logic/analysis/helper.go b/internal/logic/analysis/helper.go index 6d9a88a..2ddeb67 100644 --- a/internal/logic/analysis/helper.go +++ b/internal/logic/analysis/helper.go @@ -2,12 +2,21 @@ package analysis import ( "sort" + "strings" "ba-torment-data-process/internal/types" ) const PlatinumRankLimit = 20000 +// ExtractGroupID extracts group ID from content_id (e.g., "3S26-1" -> "3S26") +func ExtractGroupID(contentID string) string { + if idx := strings.Index(contentID, "-"); idx != -1 { + return contentID[:idx] + } + return contentID +} + // 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) { diff --git a/internal/logic/schaledb/i18n.go b/internal/logic/schaledb/i18n.go new file mode 100644 index 0000000..9601ede --- /dev/null +++ b/internal/logic/schaledb/i18n.go @@ -0,0 +1,68 @@ +package schaledb + +import ( + "context" + "encoding/json" + "log" + + "ba-torment-data-process/internal/constants" + "ba-torment-data-process/internal/db/postgres" + logic_download "ba-torment-data-process/internal/logic/download" +) + +type localizationRaw struct { + School map[string]string `json:"School"` + Club map[string]string `json:"Club"` +} + +func loadLocalizationFull(lang string) *localizationRaw { + url := constants.SchaleDBURL + "data/" + lang + "/localization.min.json" + byteValue := logic_download.GetDataFromURL(url) + + var data localizationRaw + if err := json.Unmarshal(byteValue, &data); err != nil { + log.Fatalf("Failed to unmarshal localization (%s): %v", lang, err) + } + + return &data +} + +func SaveI18nData(db *postgres.Queries) error { + kr := loadLocalizationFull("kr") + ja := loadLocalizationFull("jp") + en := loadLocalizationFull("en") + + ctx := context.Background() + + // Save School + for key := range kr.School { + err := db.UpsertI18n(ctx, postgres.UpsertI18nParams{ + Category: "school", + Key: key, + NameKo: kr.School[key], + NameJa: ja.School[key], + NameEn: en.School[key], + }) + if err != nil { + return err + } + log.Printf("Saved i18n school: %s", key) + } + + // Save Club + for key := range kr.Club { + err := db.UpsertI18n(ctx, postgres.UpsertI18nParams{ + Category: "club", + Key: key, + NameKo: kr.Club[key], + NameJa: ja.Club[key], + NameEn: en.Club[key], + }) + if err != nil { + return err + } + log.Printf("Saved i18n club: %s", key) + } + + return nil +} diff --git a/internal/logic/upload/upload.go b/internal/logic/upload/upload.go index 78f4075..9fe3a86 100644 --- a/internal/logic/upload/upload.go +++ b/internal/logic/upload/upload.go @@ -66,7 +66,7 @@ func UploadFile(path string, fileName string, data []byte, dryRun bool) error { log.Fatalf("API request failed: %v", err) } - req.Header.Set("X-Access-Token", logic.GetEnv("FILE_MANAGER_SERVICE_API_KEY", "")) + req.Header.Set("X-Access-Token", logic.GetEnv("BA_ANALYZER_SERVICE_TOKEN", "")) req.Header.Set("Content-Type", writer.FormDataContentType()) client := &http.Client{} diff --git a/internal/types/analysis_types.go b/internal/types/analysis_types.go index c08920a..ea63fd5 100644 --- a/internal/types/analysis_types.go +++ b/internal/types/analysis_types.go @@ -45,11 +45,14 @@ type CharacterSynergy struct { // 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"` + StudentID int `json:"studentId"` + UsageHistory []RaidUsage `json:"usageHistory"` + StarDistribution *RaidStarDistribution `json:"starDistribution"` // Latest distribution (200+ usage), null if none + AssistStats AssistUsageStats `json:"assistStats"` + TopSynergyChars []CharacterSynergy `json:"topSynergyChars"` + TotalUsage int `json:"totalUsage"` + OverallRank int `json:"overallRank"` + CategoryRank int `json:"categoryRank"` } // TotalAnalysisOutput represents the final output of total analysis diff --git a/internal/types/schaledb_students.go b/internal/types/schaledb_students.go index 2bcb447..94272ee 100644 --- a/internal/types/schaledb_students.go +++ b/internal/types/schaledb_students.go @@ -87,24 +87,29 @@ type StudentSkill struct { // Skill effect type SkillEffect struct { Type string `json:"Type"` + Restrictions []SkillRestriction `json:"Restrictions,omitempty"` Target []string `json:"Target,omitempty"` Scale []int `json:"Scale,omitempty"` Value [][]int `json:"Value,omitempty"` AdditionalValue any `json:"AdditionalValue,omitempty"` - Channel int `json:"Channel,omitempty"` Stat string `json:"Stat,omitempty"` Duration int `json:"Duration,omitempty"` Period int `json:"Period,omitempty"` - ApplyFrame int `json:"ApplyFrame,omitempty"` Block int `json:"Block,omitempty"` CriticalCheck string `json:"CriticalCheck,omitempty"` - DescParamId int `json:"DescParamId,omitempty"` Hits []int `json:"Hits,omitempty"` ExtraStatRate []int `json:"ExtraStatRate,omitempty"` ExtraStatSource string `json:"ExtraStatSource,omitempty"` TargetHpRateModifier *TargetHpRateModifier `json:"TargetHpRateModifier,omitempty"` } +// Skill restriction (e.g., BulletType == Pierce) +type SkillRestriction struct { + Property string `json:"Property"` + Operand string `json:"Operand"` + Value string `json:"Value"` +} + // HP-based damage modifier type TargetHpRateModifier struct { MaxHpRate int `json:"MaxHpRate"`