|
9 | 9 | "log" |
10 | 10 | "net/http" |
11 | 11 | "os" |
| 12 | + "strconv" |
| 13 | + "strings" |
12 | 14 | "sync" |
13 | 15 | "time" |
14 | 16 |
|
@@ -346,6 +348,31 @@ var defaultSwordPrices = []SwordPrice{ |
346 | 348 | {Level: 15, MinPrice: 30000000, MaxPrice: 40000000, AvgPrice: 35000000}, |
347 | 349 | } |
348 | 350 |
|
| 351 | +// extractTypeLevel 키에서 타입과 레벨 추출 |
| 352 | +// 키 형식: "{type}_{level}" (예: "normal_10", "special_5", "trash_3") |
| 353 | +// 반환: (타입, 레벨, 성공여부) |
| 354 | +func extractTypeLevel(key string) (string, int, bool) { |
| 355 | + parts := strings.Split(key, "_") |
| 356 | + if len(parts) < 2 { |
| 357 | + return "", 0, false |
| 358 | + } |
| 359 | + |
| 360 | + // 마지막 부분이 레벨 숫자 |
| 361 | + levelStr := parts[len(parts)-1] |
| 362 | + level, err := strconv.Atoi(levelStr) |
| 363 | + if err != nil { |
| 364 | + return "", 0, false |
| 365 | + } |
| 366 | + |
| 367 | + // 나머지가 타입 (normal, special, trash만 허용) |
| 368 | + itemType := strings.Join(parts[:len(parts)-1], "_") |
| 369 | + if itemType != "normal" && itemType != "special" && itemType != "trash" { |
| 370 | + return "", 0, false |
| 371 | + } |
| 372 | + |
| 373 | + return itemType, level, true |
| 374 | +} |
| 375 | + |
349 | 376 | func getGameData() GameData { |
350 | 377 | stats.mu.RLock() |
351 | 378 | defer stats.mu.RUnlock() |
@@ -383,9 +410,49 @@ func getGameData() GameData { |
383 | 410 | } |
384 | 411 | } |
385 | 412 |
|
| 413 | + // 검 가격: 실측 판매 데이터 반영 |
| 414 | + swordPrices := make([]SwordPrice, len(defaultSwordPrices)) |
| 415 | + copy(swordPrices, defaultSwordPrices) |
| 416 | + |
| 417 | + // swordSaleStats에서 레벨별 판매 통계 집계 |
| 418 | + // 키 형식: "{검이름}_{레벨}" (예: "불꽃검_10", "검_8") |
| 419 | + levelSales := make(map[int]struct { |
| 420 | + totalPrice int |
| 421 | + count int |
| 422 | + }) |
| 423 | + for key, stat := range stats.swordSaleStats { |
| 424 | + // 키에서 레벨 추출 (마지막 "_" 뒤의 숫자) |
| 425 | + parts := strings.Split(key, "_") |
| 426 | + if len(parts) < 2 { |
| 427 | + continue |
| 428 | + } |
| 429 | + levelStr := parts[len(parts)-1] |
| 430 | + level, err := strconv.Atoi(levelStr) |
| 431 | + if err != nil { |
| 432 | + continue |
| 433 | + } |
| 434 | + // 레벨별로 집계 |
| 435 | + entry := levelSales[level] |
| 436 | + entry.totalPrice += stat.TotalPrice |
| 437 | + entry.count += stat.Count |
| 438 | + levelSales[level] = entry |
| 439 | + } |
| 440 | + |
| 441 | + // 실측 평균 가격으로 대체 (minSampleSize 이상일 때만) |
| 442 | + for i := range swordPrices { |
| 443 | + lvl := swordPrices[i].Level |
| 444 | + if entry, ok := levelSales[lvl]; ok && entry.count >= minSampleSize { |
| 445 | + realAvgPrice := entry.totalPrice / entry.count |
| 446 | + swordPrices[i].AvgPrice = realAvgPrice |
| 447 | + // MinPrice, MaxPrice도 실측 기준으로 추정 (±20%) |
| 448 | + swordPrices[i].MinPrice = int(float64(realAvgPrice) * 0.8) |
| 449 | + swordPrices[i].MaxPrice = int(float64(realAvgPrice) * 1.2) |
| 450 | + } |
| 451 | + } |
| 452 | + |
386 | 453 | return GameData{ |
387 | 454 | EnhanceRates: enhanceRates, |
388 | | - SwordPrices: defaultSwordPrices, |
| 455 | + SwordPrices: swordPrices, |
389 | 456 | BattleRewards: battleRewards, |
390 | 457 | UpdatedAt: time.Now().Format(time.RFC3339), |
391 | 458 | } |
@@ -920,12 +987,41 @@ func handleOptimalSellPoint(w http.ResponseWriter, r *http.Request) { |
920 | 987 | } |
921 | 988 |
|
922 | 989 | // 예상 시간 계산 (초 단위) |
923 | | - // 파밍 시간 + 강화 시간 |
924 | | - // 파밍: 약 3초, 강화: 약 2초/회 |
| 990 | + // 실제 클라이언트 설정 기반: |
| 991 | + // - TrashDelay: 1.2초 (파밍/판매 후) |
| 992 | + // - LowDelay: 1.5초 (0-8강) |
| 993 | + // - MidDelay: 2.5초 (9강) |
| 994 | + // - HighDelay: 3.5초 (10강+) |
| 995 | + // + 응답 대기/처리 오버헤드: 약 1초 |
925 | 996 | calcExpectedTime := func(targetLevel int) float64 { |
926 | | - farmTime := 3.0 // 아이템 파밍 |
927 | | - enhanceTime := calcExpectedTrials(targetLevel) * 2.0 // 강화당 2초 |
928 | | - return farmTime + enhanceTime |
| 997 | + const ( |
| 998 | + farmTime = 1.2 // TrashDelay (판매 후 새 검 받기) |
| 999 | + lowDelay = 2.5 // LowDelay(1.5) + 응답대기(1.0) |
| 1000 | + midDelay = 3.5 // MidDelay(2.5) + 응답대기(1.0) |
| 1001 | + highDelay = 4.5 // HighDelay(3.5) + 응답대기(1.0) |
| 1002 | + slowdownLvl = 9 // SlowdownLevel |
| 1003 | + ) |
| 1004 | + |
| 1005 | + totalTime := farmTime |
| 1006 | + for lvl := 0; lvl < targetLevel && lvl < len(gameData.EnhanceRates); lvl++ { |
| 1007 | + rate := gameData.EnhanceRates[lvl].SuccessRate / 100.0 |
| 1008 | + if rate <= 0 { |
| 1009 | + continue |
| 1010 | + } |
| 1011 | + expectedTries := 1.0 / rate |
| 1012 | + |
| 1013 | + // 레벨별 딜레이 적용 |
| 1014 | + var delay float64 |
| 1015 | + if lvl >= 10 { |
| 1016 | + delay = highDelay |
| 1017 | + } else if lvl >= slowdownLvl { |
| 1018 | + delay = midDelay |
| 1019 | + } else { |
| 1020 | + delay = lowDelay |
| 1021 | + } |
| 1022 | + totalTime += expectedTries * delay |
| 1023 | + } |
| 1024 | + return totalTime |
929 | 1025 | } |
930 | 1026 |
|
931 | 1027 | type LevelEfficiency struct { |
@@ -984,10 +1080,181 @@ func handleOptimalSellPoint(w http.ResponseWriter, r *http.Request) { |
984 | 1080 | } |
985 | 1081 | } |
986 | 1082 |
|
| 1083 | + // 타입별 판매가 집계 (normal_10, special_10 등에서 추출) |
| 1084 | + typeLevelPrices := make(map[string]map[int]struct { |
| 1085 | + totalPrice int |
| 1086 | + count int |
| 1087 | + }) |
| 1088 | + for key, stat := range stats.swordSaleStats { |
| 1089 | + itemType, level, ok := extractTypeLevel(key) |
| 1090 | + if !ok { |
| 1091 | + continue |
| 1092 | + } |
| 1093 | + if typeLevelPrices[itemType] == nil { |
| 1094 | + typeLevelPrices[itemType] = make(map[int]struct { |
| 1095 | + totalPrice int |
| 1096 | + count int |
| 1097 | + }) |
| 1098 | + } |
| 1099 | + entry := typeLevelPrices[itemType][level] |
| 1100 | + entry.totalPrice += stat.TotalPrice |
| 1101 | + entry.count += stat.Count |
| 1102 | + typeLevelPrices[itemType][level] = entry |
| 1103 | + } |
| 1104 | + |
| 1105 | + // 타입별 강화 확률 집계 (normal_10, special_10 등에서 추출) |
| 1106 | + typeLevelEnhance := make(map[string]map[int]struct { |
| 1107 | + attempts int |
| 1108 | + success int |
| 1109 | + }) |
| 1110 | + for key, stat := range stats.swordEnhanceStats { |
| 1111 | + itemType, level, ok := extractTypeLevel(key) |
| 1112 | + if !ok { |
| 1113 | + continue |
| 1114 | + } |
| 1115 | + if typeLevelEnhance[itemType] == nil { |
| 1116 | + typeLevelEnhance[itemType] = make(map[int]struct { |
| 1117 | + attempts int |
| 1118 | + success int |
| 1119 | + }) |
| 1120 | + } |
| 1121 | + entry := typeLevelEnhance[itemType][level] |
| 1122 | + entry.attempts += stat.Attempts |
| 1123 | + entry.success += stat.Success |
| 1124 | + typeLevelEnhance[itemType][level] = entry |
| 1125 | + } |
| 1126 | + |
| 1127 | + // 타입별 강화 성공률 계산 (샘플 부족 시 기본값 사용) |
| 1128 | + getEnhanceRateForType := func(itemType string, level int) float64 { |
| 1129 | + if typeData, ok := typeLevelEnhance[itemType]; ok { |
| 1130 | + if entry, ok := typeData[level]; ok && entry.attempts >= minSampleSize { |
| 1131 | + return float64(entry.success) / float64(entry.attempts) |
| 1132 | + } |
| 1133 | + } |
| 1134 | + // 기본값 사용 |
| 1135 | + if level < len(gameData.EnhanceRates) { |
| 1136 | + return gameData.EnhanceRates[level].SuccessRate / 100.0 |
| 1137 | + } |
| 1138 | + return 0.05 // 매우 낮은 기본값 |
| 1139 | + } |
| 1140 | + |
| 1141 | + // 타입별 평균 판매가 계산 (샘플 부족 시 기본값 사용) |
| 1142 | + getAvgPriceForType := func(itemType string, level int) int { |
| 1143 | + if typeData, ok := typeLevelPrices[itemType]; ok { |
| 1144 | + if entry, ok := typeData[level]; ok && entry.count >= minSampleSize { |
| 1145 | + return entry.totalPrice / entry.count |
| 1146 | + } |
| 1147 | + } |
| 1148 | + // 기본값 사용 |
| 1149 | + if level < len(gameData.SwordPrices) { |
| 1150 | + return gameData.SwordPrices[level].AvgPrice |
| 1151 | + } |
| 1152 | + return 0 |
| 1153 | + } |
| 1154 | + |
| 1155 | + // 타입별 예상 시간 계산 |
| 1156 | + calcExpectedTimeForType := func(itemType string, targetLevel int) float64 { |
| 1157 | + const ( |
| 1158 | + farmTime = 1.2 |
| 1159 | + lowDelay = 2.5 |
| 1160 | + midDelay = 3.5 |
| 1161 | + highDelay = 4.5 |
| 1162 | + slowdownLvl = 9 |
| 1163 | + ) |
| 1164 | + |
| 1165 | + totalTime := farmTime |
| 1166 | + for lvl := 0; lvl < targetLevel; lvl++ { |
| 1167 | + rate := getEnhanceRateForType(itemType, lvl) |
| 1168 | + if rate <= 0 { |
| 1169 | + continue |
| 1170 | + } |
| 1171 | + expectedTries := 1.0 / rate |
| 1172 | + |
| 1173 | + var delay float64 |
| 1174 | + if lvl >= 10 { |
| 1175 | + delay = highDelay |
| 1176 | + } else if lvl >= slowdownLvl { |
| 1177 | + delay = midDelay |
| 1178 | + } else { |
| 1179 | + delay = lowDelay |
| 1180 | + } |
| 1181 | + totalTime += expectedTries * delay |
| 1182 | + } |
| 1183 | + return totalTime |
| 1184 | + } |
| 1185 | + |
| 1186 | + // 타입별 최적 레벨 계산 |
| 1187 | + type TypeOptimal struct { |
| 1188 | + Type string `json:"type"` |
| 1189 | + OptimalLevel int `json:"optimal_level"` |
| 1190 | + OptimalGPM float64 `json:"optimal_gpm"` |
| 1191 | + SampleSize int `json:"sample_size"` |
| 1192 | + EnhanceSamples int `json:"enhance_samples"` |
| 1193 | + IsDefault bool `json:"is_default"` |
| 1194 | + } |
| 1195 | + |
| 1196 | + calcTypeOptimal := func(itemType string) TypeOptimal { |
| 1197 | + bestLvl := 10 |
| 1198 | + bestGpm := 0.0 |
| 1199 | + totalSales := 0 |
| 1200 | + totalEnhance := 0 |
| 1201 | + |
| 1202 | + // 해당 타입의 총 샘플 수 계산 |
| 1203 | + if typeData, ok := typeLevelPrices[itemType]; ok { |
| 1204 | + for _, entry := range typeData { |
| 1205 | + totalSales += entry.count |
| 1206 | + } |
| 1207 | + } |
| 1208 | + if typeData, ok := typeLevelEnhance[itemType]; ok { |
| 1209 | + for _, entry := range typeData { |
| 1210 | + totalEnhance += entry.attempts |
| 1211 | + } |
| 1212 | + } |
| 1213 | + |
| 1214 | + isDefault := totalSales < minSampleSize || totalEnhance < minSampleSize |
| 1215 | + |
| 1216 | + for level := 5; level <= 15; level++ { |
| 1217 | + price := getAvgPriceForType(itemType, level) |
| 1218 | + timeSeconds := calcExpectedTimeForType(itemType, level) |
| 1219 | + |
| 1220 | + // 성공 확률 계산 |
| 1221 | + successProb := 1.0 |
| 1222 | + for lvl := 0; lvl < level; lvl++ { |
| 1223 | + successProb *= getEnhanceRateForType(itemType, lvl) |
| 1224 | + } |
| 1225 | + |
| 1226 | + gpm := 0.0 |
| 1227 | + if timeSeconds > 0 { |
| 1228 | + gpm = (float64(price) * successProb) / (timeSeconds / 60.0) |
| 1229 | + } |
| 1230 | + |
| 1231 | + if gpm > bestGpm { |
| 1232 | + bestGpm = gpm |
| 1233 | + bestLvl = level |
| 1234 | + } |
| 1235 | + } |
| 1236 | + |
| 1237 | + return TypeOptimal{ |
| 1238 | + Type: itemType, |
| 1239 | + OptimalLevel: bestLvl, |
| 1240 | + OptimalGPM: bestGpm, |
| 1241 | + SampleSize: totalSales, |
| 1242 | + EnhanceSamples: totalEnhance, |
| 1243 | + IsDefault: isDefault, |
| 1244 | + } |
| 1245 | + } |
| 1246 | + |
| 1247 | + typeOptimalLevels := map[string]TypeOptimal{ |
| 1248 | + "normal": calcTypeOptimal("normal"), |
| 1249 | + "special": calcTypeOptimal("special"), |
| 1250 | + "trash": calcTypeOptimal("trash"), |
| 1251 | + } |
| 1252 | + |
987 | 1253 | json.NewEncoder(w).Encode(map[string]interface{}{ |
988 | 1254 | "optimal_level": bestLevel, |
989 | 1255 | "optimal_gpm": bestGPM, |
990 | 1256 | "level_efficiencies": efficiencies, |
| 1257 | + "by_type": typeOptimalLevels, |
991 | 1258 | "note": "gold_per_minute = (avg_price × success_prob) / (expected_time / 60)", |
992 | 1259 | }) |
993 | 1260 | } |
|
0 commit comments