dubs-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * 🎨 Matchup Image Generator Service
3
+ *
4
+ * Server-side generation of matchup images using node-canvas.
5
+ * Generates a combined PNG image with:
6
+ * - Left/right team logos with radial gradient backgrounds
7
+ * - League logo centered on the seam
8
+ * - Noise texture overlay
9
+ * - Center highlight band
10
+ *
11
+ * Images are generated ONCE at game creation and uploaded to S3.
12
+ */
13
+
14
+ const { createCanvas, loadImage } = require('canvas');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+
18
+ // Configuration - Full quality size (will be resized to 300x158 on upload for optimization)
19
+ const DEFAULT_WIDTH = 600;
20
+ const DEFAULT_HEIGHT = 315;
21
+ const TEAM_LOGO_MAX_SIZE = 200;
22
+ const LEAGUE_LOGO_MAX_SIZE = 60;
23
+
24
+ // Palette extraction settings
25
+ const ALPHA_THRESHOLD = 24;
26
+ const SATURATION_THRESHOLD = 18;
27
+ const MIN_CHANNEL_MAX = 30;
28
+ const SAMPLE_MAX_PIXELS = 45000;
29
+ const EXTRACTION_MAX_DIMENSION = 320;
30
+
31
+ // Gradient settings
32
+ const INNER_STOP = 0.45;
33
+ const NOISE_STRENGTH = 0.06;
34
+ const HIGHLIGHT_WIDTH = 80;
35
+ const HIGHLIGHT_ALPHA = 0.35;
36
+
37
+ /**
38
+ * Simple hash function for deterministic noise
39
+ */
40
+ function hash(x, y) {
41
+ let h = x * 374761393 + y * 668265263;
42
+ h = (h ^ (h >> 15)) * 2246822507;
43
+ h = (h ^ (h >> 13)) * 3266489917;
44
+ return (h ^ (h >> 16)) >>> 0;
45
+ }
46
+
47
+ /**
48
+ * Convert RGB to hex string
49
+ */
50
+ function rgbToHex(r, g, b) {
51
+ const toHex = (n) => {
52
+ const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16);
53
+ return hex.length === 1 ? '0' + hex : hex;
54
+ };
55
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
56
+ }
57
+
58
+ /**
59
+ * Extract color palette from a logo image
60
+ */
61
+ function extractLogoPalette(img, canvas, ctx) {
62
+ // Scale down for faster processing
63
+ const scale = Math.min(
64
+ EXTRACTION_MAX_DIMENSION / img.width,
65
+ EXTRACTION_MAX_DIMENSION / img.height,
66
+ 1
67
+ );
68
+ const extractWidth = Math.floor(img.width * scale);
69
+ const extractHeight = Math.floor(img.height * scale);
70
+
71
+ // Create temporary canvas for extraction
72
+ const extractCanvas = createCanvas(extractWidth, extractHeight);
73
+ const extractCtx = extractCanvas.getContext('2d');
74
+
75
+ // Draw logo
76
+ extractCtx.drawImage(img, 0, 0, extractWidth, extractHeight);
77
+
78
+ // Get image data
79
+ const imageData = extractCtx.getImageData(0, 0, extractWidth, extractHeight);
80
+ const data = imageData.data;
81
+ const pixels = [];
82
+
83
+ // Collect valid logo pixels
84
+ for (let i = 0; i < data.length; i += 4) {
85
+ const r = data[i];
86
+ const g = data[i + 1];
87
+ const b = data[i + 2];
88
+ const a = data[i + 3];
89
+
90
+ // Skip transparent pixels
91
+ if (a < ALPHA_THRESHOLD) continue;
92
+
93
+ // Calculate luminance and saturation
94
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
95
+ const max = Math.max(r, g, b);
96
+ const min = Math.min(r, g, b);
97
+ const saturation = max - min;
98
+
99
+ // Filter to logo pixels (saturated, visible colors)
100
+ if (saturation >= SATURATION_THRESHOLD && max >= MIN_CHANNEL_MAX) {
101
+ pixels.push({ r, g, b, luminance, saturation });
102
+ }
103
+ }
104
+
105
+ // If too few pixels, relax saturation threshold
106
+ let validPixels = pixels;
107
+ if (validPixels.length < 100) {
108
+ validPixels = pixels.filter(p => p.saturation >= Math.max(5, SATURATION_THRESHOLD - 5));
109
+ }
110
+
111
+ if (validPixels.length === 0) {
112
+ // Fallback to a neutral palette
113
+ return {
114
+ inner: '#4A90E2',
115
+ mid: '#2C5F8A',
116
+ outer: '#1A3A5A'
117
+ };
118
+ }
119
+
120
+ // Sample deterministically (stride-based)
121
+ const stride = Math.max(1, Math.floor(validPixels.length / SAMPLE_MAX_PIXELS));
122
+ const sampled = validPixels.filter((_, i) => i % stride === 0);
123
+
124
+ // Sort by vibrancy (saturation × luminance) for inner color
125
+ const sortedByVibrancy = [...sampled].sort(
126
+ (a, b) => (b.saturation * b.luminance) - (a.saturation * a.luminance)
127
+ );
128
+ const innerPixel = sortedByVibrancy[0] || sampled[0];
129
+
130
+ // Find median luminance for mid color
131
+ const sortedByLuminance = [...sampled].sort((a, b) => a.luminance - b.luminance);
132
+ const medianIndex = Math.floor(sampled.length / 2);
133
+ const midPixel = sortedByLuminance[medianIndex] || sampled[0];
134
+
135
+ // Find dark anchor for outer color
136
+ const darkPixels = sampled.filter(p => p.luminance < 100 && p.luminance > 10);
137
+ const outerPixel = darkPixels.length > 0
138
+ ? darkPixels.sort((a, b) => a.luminance - b.luminance)[0]
139
+ : sortedByLuminance[0] || sampled[0];
140
+
141
+ return {
142
+ inner: rgbToHex(innerPixel.r, innerPixel.g, innerPixel.b),
143
+ mid: rgbToHex(midPixel.r, midPixel.g, midPixel.b),
144
+ outer: rgbToHex(outerPixel.r, outerPixel.g, outerPixel.b)
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Draw radial gradient column
150
+ */
151
+ function drawRadialGradientColumn(ctx, x, y, width, height, palette) {
152
+ const centerX = x + width / 2;
153
+ const centerY = y + height / 2;
154
+ const radius = Math.sqrt(
155
+ Math.pow(Math.max(centerX - x, x + width - centerX), 2) +
156
+ Math.pow(Math.max(centerY - y, y + height - centerY), 2)
157
+ );
158
+
159
+ const gradient = ctx.createRadialGradient(
160
+ centerX, centerY, 0,
161
+ centerX, centerY, radius
162
+ );
163
+
164
+ gradient.addColorStop(0, palette.inner);
165
+ gradient.addColorStop(INNER_STOP, palette.mid);
166
+ gradient.addColorStop(1, palette.outer);
167
+
168
+ ctx.fillStyle = gradient;
169
+ ctx.fillRect(x, y, width, height);
170
+ }
171
+
172
+ /**
173
+ * Draw deterministic noise texture overlay
174
+ */
175
+ function drawNoiseTexture(ctx, width, height) {
176
+ const existingData = ctx.getImageData(0, 0, width, height);
177
+ const existing = existingData.data;
178
+
179
+ for (let y = 0; y < height; y++) {
180
+ for (let x = 0; x < width; x++) {
181
+ const index = (y * width + x) * 4;
182
+ const noiseValue = (hash(x, y) % 256) / 255;
183
+ const gray = Math.floor(noiseValue * 255);
184
+ const alpha = noiseValue * NOISE_STRENGTH;
185
+
186
+ existing[index] = Math.floor(gray * alpha + existing[index] * (1 - alpha));
187
+ existing[index + 1] = Math.floor(gray * alpha + existing[index + 1] * (1 - alpha));
188
+ existing[index + 2] = Math.floor(gray * alpha + existing[index + 2] * (1 - alpha));
189
+ }
190
+ }
191
+
192
+ ctx.putImageData(existingData, 0, 0);
193
+ }
194
+
195
+ /**
196
+ * Draw center highlight band
197
+ */
198
+ function drawCenterHighlight(ctx, width, height) {
199
+ const centerX = width / 2;
200
+ const halfWidth = HIGHLIGHT_WIDTH / 2;
201
+
202
+ const gradient = ctx.createLinearGradient(
203
+ centerX - halfWidth, 0,
204
+ centerX + halfWidth, 0
205
+ );
206
+
207
+ gradient.addColorStop(0, `rgba(255, 255, 255, 0)`);
208
+ gradient.addColorStop(0.5, `rgba(255, 255, 255, ${HIGHLIGHT_ALPHA})`);
209
+ gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
210
+
211
+ ctx.fillStyle = gradient;
212
+ ctx.fillRect(centerX - halfWidth, 0, HIGHLIGHT_WIDTH, height);
213
+ }
214
+
215
+ /**
216
+ * Draw image with contain fit (maintains aspect ratio, centered)
217
+ */
218
+ function drawContain(ctx, img, centerX, centerY, maxWidth, maxHeight) {
219
+ const imgAspect = img.width / img.height;
220
+ const maxAspect = maxWidth / maxHeight;
221
+
222
+ let drawWidth, drawHeight;
223
+
224
+ if (imgAspect > maxAspect) {
225
+ drawWidth = maxWidth;
226
+ drawHeight = maxWidth / imgAspect;
227
+ } else {
228
+ drawHeight = maxHeight;
229
+ drawWidth = maxHeight * imgAspect;
230
+ }
231
+
232
+ const x = centerX - drawWidth / 2;
233
+ const y = centerY - drawHeight / 2;
234
+
235
+ ctx.drawImage(img, x, y, drawWidth, drawHeight);
236
+ }
237
+
238
+ // Base URL for loading logos - can be the SPA's public folder or S3
239
+ const LOGO_BASE_URL = process.env.MATCHUP_LOGO_BASE_URL || 'https://dubs.app';
240
+
241
+ /**
242
+ * Team name mappings for cases where TheSportsDB name differs from our file names
243
+ * Key: TheSportsDB team name (lowercase)
244
+ * Value: Our file name (without extension)
245
+ */
246
+ const TEAM_NAME_OVERRIDES = {
247
+ // EPL - Map API names to local file names
248
+ 'bournemouth': 'afc_bournemouth',
249
+ 'brighton': 'brighton_and_hove_albion',
250
+ 'brighton & hove albion': 'brighton_and_hove_albion',
251
+ 'brighton and hove albion': 'brighton_and_hove_albion',
252
+ 'man city': 'manchester_city',
253
+ 'man utd': 'manchester_united',
254
+ 'wolves': 'wolverhampton_wanderers',
255
+ 'spurs': 'tottenham_hotspur',
256
+ 'tottenham': 'tottenham_hotspur',
257
+ "nott'm forest": 'nottingham_forest',
258
+ 'nottingham': 'nottingham_forest',
259
+ 'leeds': 'leeds_united',
260
+ 'newcastle': 'newcastle_united',
261
+ 'west ham': 'west_ham_united',
262
+
263
+ // NHL - Handle Utah team rename
264
+ 'utah hockey club': 'utah_mammoth',
265
+ 'arizona coyotes': 'utah_mammoth',
266
+
267
+ // NBA
268
+ 'la clippers': 'la_clippers',
269
+
270
+ // NCAAB - Map ESPN names to our file names
271
+ 'uconn huskies': 'connecticut',
272
+ 'uconn': 'connecticut',
273
+ "st. john's red storm": 'st_johns',
274
+ "st. john's": 'st_johns',
275
+ 'ole miss rebels': 'mississippi',
276
+ 'ole miss': 'mississippi',
277
+ 'pitt panthers': 'pittsburgh',
278
+ 'pitt': 'pittsburgh',
279
+ 'lsu tigers': 'lsu',
280
+ 'smu mustangs': 'southern_methodist',
281
+ 'smu': 'southern_methodist',
282
+ 'ucf knights': 'ucf_knights',
283
+ 'tcu horned frogs': 'tcu',
284
+ 'unlv rebels': 'unlv',
285
+ 'utep miners': 'utep',
286
+ 'vcu rams': 'vcu',
287
+ 'uab blazers': 'uab',
288
+ 'stanford cardinal': 'stanford',
289
+ 'rutgers scarlet knights': 'rutgers',
290
+ };
291
+
292
+ /**
293
+ * Common college mascot names to strip from team names
294
+ */
295
+ const NCAAB_MASCOTS = [
296
+ 'wildcats', 'bulldogs', 'tigers', 'eagles', 'hawks', 'bears', 'lions',
297
+ 'cougars', 'huskies', 'spartans', 'wolverines', 'buckeyes', 'gophers',
298
+ 'badgers', 'hoosiers', 'boilermakers', 'hawkeyes', 'cornhuskers',
299
+ 'jayhawks', 'sooners', 'longhorns', 'aggies', 'red raiders', 'horned frogs',
300
+ 'cowboys', 'razorbacks', 'volunteers', 'commodores', 'gamecocks',
301
+ 'crimson tide', 'fighting irish', 'blue devils', 'tar heels', 'wolfpack',
302
+ 'demon deacons', 'cavaliers', 'hokies', 'yellow jackets', 'seminoles',
303
+ 'hurricanes', 'orange', 'cardinals', 'cardinal', 'fighting illini', 'golden gophers',
304
+ 'nittany lions', 'mountaineers', 'panthers', 'blue jays', 'red storm',
305
+ 'friars', 'musketeers', 'pirates', 'colonials', 'rams', 'flyers', 'explorers',
306
+ 'billikens', 'bonnies', 'braves', 'bison', 'phoenix', 'knights', 'scarlet knights', 'owls',
307
+ 'pilots', 'waves', 'gaels', 'dons', 'broncos', 'toreros', 'aztecs',
308
+ 'rebels', 'utes', 'coyotes', 'anteaters', 'tritons', 'matadors', 'titans',
309
+ 'mustangs', 'gauchos', 'hornets', 'lumberjacks', 'vandals',
310
+ 'grizzlies', 'bobcats', 'thunderbirds', 'roadrunners', 'miners', 'mean green',
311
+ 'blazers', 'jaguars', 'trojans', 'bruins', 'ducks', 'beavers',
312
+ 'cougs', 'sun devils', 'buffaloes', 'falcons', 'rainbow warriors', 'warriors', 'bearkats', 'cyclones'
313
+ ];
314
+
315
+ /**
316
+ * Strip mascot name from NCAAB team name
317
+ * "Duke Blue Devils" → "duke"
318
+ * "Michigan State Spartans" → "michigan_state"
319
+ */
320
+ function stripNCAABMascot(teamName) {
321
+ let name = teamName.toLowerCase();
322
+
323
+ // Sort by length (longest first) to match multi-word mascots first
324
+ const sortedMascots = [...NCAAB_MASCOTS].sort((a, b) => b.length - a.length);
325
+
326
+ for (const mascot of sortedMascots) {
327
+ if (name.endsWith(' ' + mascot)) {
328
+ name = name.slice(0, name.length - mascot.length - 1).trim();
329
+ break;
330
+ }
331
+ }
332
+
333
+ return name;
334
+ }
335
+
336
+ /**
337
+ * Get team badge URL from team name
338
+ * Uses remote URL (SPA or CDN) rather than local file
339
+ */
340
+ function getTeamBadgeUrl(teamName, league) {
341
+ const lowerName = teamName.trim().toLowerCase();
342
+
343
+ // Check for overrides first
344
+ if (TEAM_NAME_OVERRIDES[lowerName]) {
345
+ return `${LOGO_BASE_URL}/major_league_logos/${league}/${TEAM_NAME_OVERRIDES[lowerName]}.png`;
346
+ }
347
+
348
+ // For NCAAB, strip mascot names (e.g., "Duke Blue Devils" → "duke")
349
+ if (league === 'NCAAB') {
350
+ const strippedName = stripNCAABMascot(teamName);
351
+ const formattedName = strippedName
352
+ .toLowerCase()
353
+ .split(' ').join('_')
354
+ .replace(/[\.,?!']/g, '')
355
+ .replace(/&/g, 'and');
356
+ return `${LOGO_BASE_URL}/major_league_logos/NCAAB/${formattedName}.png`;
357
+ }
358
+
359
+ // For UFC, find fighter file (handles partial names like "Adesanya" → "israel_adesanya")
360
+ if (league === 'UFC') {
361
+ const formattedName = findUFCFighterFile(teamName);
362
+ return `${LOGO_BASE_URL}/major_league_logos/UFC/${formattedName}.png`;
363
+ }
364
+
365
+ // Default formatting: lowercase, spaces to underscores, remove punctuation
366
+ const formattedName = lowerName.split(' ').join('_').replace(/[\.,?!]/g, '');
367
+ return `${LOGO_BASE_URL}/major_league_logos/${league}/${formattedName}.png`;
368
+ }
369
+
370
+ /**
371
+ * Normalize UFC fighter name to match local file name
372
+ * "Sean Strickland" → "sean_strickland"
373
+ */
374
+ function normalizeUFCFighterName(name) {
375
+ return name
376
+ .toLowerCase()
377
+ .replace(/\s+/g, '_')
378
+ .replace(/'/g, '')
379
+ .replace(/\./g, '')
380
+ .replace(/-/g, '_');
381
+ }
382
+
383
+ /**
384
+ * Find UFC fighter file by partial match
385
+ * Handles cases where only last name is provided (e.g., "Adesanya" → "israel_adesanya.png")
386
+ */
387
+ function findUFCFighterFile(searchName) {
388
+ const normalizedSearch = normalizeUFCFighterName(searchName);
389
+ const ufcDir = path.join(__dirname, '../../../dubs-jackpot-spa/public/major_league_logos/UFC');
390
+
391
+ try {
392
+ // First try exact match
393
+ const exactPath = path.join(ufcDir, `${normalizedSearch}.png`);
394
+ if (fs.existsSync(exactPath)) {
395
+ return normalizedSearch;
396
+ }
397
+
398
+ // If not found, search for files containing the search term
399
+ const files = fs.readdirSync(ufcDir);
400
+ for (const file of files) {
401
+ if (file.endsWith('.png') && file.includes(normalizedSearch)) {
402
+ // Return filename without extension
403
+ return file.replace('.png', '');
404
+ }
405
+ }
406
+
407
+ console.log(`[MatchupImage:UFC] No file found for fighter: ${searchName} (searched: ${normalizedSearch})`);
408
+ return normalizedSearch; // Return original as fallback
409
+ } catch (err) {
410
+ console.error(`[MatchupImage:UFC] Error searching for fighter file:`, err.message);
411
+ return normalizedSearch;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Get league logo URL
417
+ * Returns null for leagues without a league logo (NCAAB, NCAAF, UFC)
418
+ */
419
+ function getLeagueLogoUrl(league) {
420
+ const leagueLogos = {
421
+ 'NHL': 'nhl.png',
422
+ 'NBA': 'nba.png',
423
+ 'NFL': 'nfl.png',
424
+ 'MLB': 'mlb.png',
425
+ 'EPL': 'epl.png'
426
+ };
427
+ // Some leagues don't have league logos - return null for them
428
+ if (!leagueLogos[league]) {
429
+ return null;
430
+ }
431
+ return `${LOGO_BASE_URL}/major_league_logos/${league}/${leagueLogos[league]}`;
432
+ }
433
+
434
+ /**
435
+ * Load image from URL with retry logic
436
+ */
437
+ async function safeLoadImage(url, retries = 2) {
438
+ for (let attempt = 0; attempt <= retries; attempt++) {
439
+ try {
440
+ console.log(`[MatchupImage] Loading: ${url} (attempt ${attempt + 1})`);
441
+ const img = await loadImage(url);
442
+ return img;
443
+ } catch (err) {
444
+ console.warn(`[MatchupImage] Failed to load: ${url}`, err.message);
445
+ if (attempt < retries) {
446
+ await new Promise(r => setTimeout(r, 500 * (attempt + 1))); // Exponential backoff
447
+ }
448
+ }
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /**
454
+ * Generate matchup image for a game
455
+ *
456
+ * @param {Object} options
457
+ * @param {string} options.homeTeam - Home team name (e.g., "Toronto Maple Leafs" or "Sean Strickland")
458
+ * @param {string} options.awayTeam - Away team name (e.g., "Florida Panthers" or "Anthony Hernandez")
459
+ * @param {string} options.league - League abbreviation (NHL, NBA, NFL, MLB, UFC, NCAAB)
460
+ * @param {number} [options.width=600] - Output width
461
+ * @param {number} [options.height=315] - Output height
462
+ *
463
+ * @returns {Promise<{buffer: Buffer, dataUrl: string, palettes: Object}>}
464
+ */
465
+ async function generateMatchupImage(options) {
466
+ const {
467
+ homeTeam,
468
+ awayTeam,
469
+ league = 'NHL',
470
+ width = DEFAULT_WIDTH,
471
+ height = DEFAULT_HEIGHT
472
+ } = options;
473
+
474
+ // EPL and UFC use "Home vs Away" convention (Home on left), US sports use "Away @ Home" (Away on left)
475
+ const leagueUpper = league?.toUpperCase();
476
+ const isHomeFirst = leagueUpper === 'EPL' || leagueUpper === 'UFC';
477
+ const leftTeam = isHomeFirst ? homeTeam : awayTeam;
478
+ const rightTeam = isHomeFirst ? awayTeam : homeTeam;
479
+
480
+ console.log(`[MatchupImage] Generating: ${leftTeam} vs ${rightTeam} (${league}) [HomeFirst=${isHomeFirst}]`);
481
+
482
+ // Create canvas
483
+ const canvas = createCanvas(width, height);
484
+ const ctx = canvas.getContext('2d');
485
+
486
+ // Construct logo URLs from team/fighter names (same for all leagues now)
487
+ const leftLogoUrl = getTeamBadgeUrl(leftTeam, league);
488
+ const rightLogoUrl = getTeamBadgeUrl(rightTeam, league);
489
+ const leagueLogoUrl = getLeagueLogoUrl(league);
490
+
491
+ console.log(`[MatchupImage] Loading logos:`, { leftLogoUrl, rightLogoUrl, leagueLogoUrl });
492
+
493
+ const [leftImg, rightImg, leagueImg] = await Promise.all([
494
+ safeLoadImage(leftLogoUrl),
495
+ safeLoadImage(rightLogoUrl),
496
+ leagueLogoUrl ? safeLoadImage(leagueLogoUrl) : Promise.resolve(null)
497
+ ]);
498
+
499
+ // Default palettes for when logos fail to load
500
+ const defaultLeftPalette = { inner: '#4A90E2', mid: '#2C5F8A', outer: '#1A3A5A' };
501
+ const defaultRightPalette = { inner: '#E24A4A', mid: '#8A2C2C', outer: '#5A1A1A' };
502
+
503
+ // NCAAB: use white/light-gray backgrounds so single-color college logos stay visible
504
+ const whitePalette = { inner: '#FFFFFF', mid: '#F5F5F5', outer: '#EBEBEB' };
505
+ const grayPalette = { inner: '#ECEEF4', mid: '#E2E4EC', outer: '#D8DAE2' };
506
+
507
+ // Extract palettes from logos (skip for NCAAB - use white)
508
+ let leftPalette = defaultLeftPalette;
509
+ let rightPalette = defaultRightPalette;
510
+
511
+ if (leagueUpper === 'NCAAB') {
512
+ leftPalette = whitePalette;
513
+ rightPalette = grayPalette;
514
+ } else {
515
+ if (leftImg) {
516
+ leftPalette = extractLogoPalette(leftImg, canvas, ctx);
517
+ }
518
+ if (rightImg) {
519
+ rightPalette = extractLogoPalette(rightImg, canvas, ctx);
520
+ }
521
+ }
522
+
523
+ // Draw left column gradient (away team)
524
+ const columnWidth = width / 2;
525
+ drawRadialGradientColumn(ctx, 0, 0, columnWidth, height, leftPalette);
526
+
527
+ // Draw right column gradient (home team)
528
+ drawRadialGradientColumn(ctx, columnWidth, 0, columnWidth, height, rightPalette);
529
+
530
+ // Draw noise texture
531
+ drawNoiseTexture(ctx, width, height);
532
+
533
+ // Draw center highlight
534
+ drawCenterHighlight(ctx, width, height);
535
+
536
+ // Draw team logos
537
+ if (leftImg) {
538
+ drawContain(ctx, leftImg, columnWidth / 2, height / 2, TEAM_LOGO_MAX_SIZE, TEAM_LOGO_MAX_SIZE);
539
+ }
540
+ if (rightImg) {
541
+ drawContain(ctx, rightImg, columnWidth + columnWidth / 2, height / 2, TEAM_LOGO_MAX_SIZE, TEAM_LOGO_MAX_SIZE);
542
+ }
543
+
544
+ // Draw league logo centered on seam
545
+ if (leagueImg) {
546
+ drawContain(ctx, leagueImg, width / 2, height / 2, LEAGUE_LOGO_MAX_SIZE, LEAGUE_LOGO_MAX_SIZE);
547
+ }
548
+
549
+ // Export as PNG buffer
550
+ const buffer = canvas.toBuffer('image/png');
551
+ const dataUrl = canvas.toDataURL('image/png');
552
+
553
+ console.log(`[MatchupImage] Generated image: ${buffer.length} bytes`);
554
+
555
+ return {
556
+ buffer,
557
+ dataUrl,
558
+ palettes: {
559
+ left: leftPalette,
560
+ right: rightPalette
561
+ },
562
+ size: { width, height }
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Supported leagues for matchup image generation
568
+ */
569
+ const SUPPORTED_LEAGUES = ['NHL', 'NBA', 'NFL', 'MLB', 'EPL', 'NCAAB', 'UFC'];
570
+
571
+ /**
572
+ * Check if a league is supported for matchup images
573
+ */
574
+ function isLeagueSupported(league) {
575
+ return SUPPORTED_LEAGUES.includes(league?.toUpperCase());
576
+ }
577
+
578
+ /**
579
+ * Check if we can generate a matchup image for a game
580
+ * (We can generate for any game with team names and a supported league)
581
+ */
582
+ function canGenerateMatchupImage(homeTeam, awayTeam, league) {
583
+ return !!(homeTeam && awayTeam && isLeagueSupported(league));
584
+ }
585
+
586
+ module.exports = {
587
+ generateMatchupImage,
588
+ isLeagueSupported,
589
+ canGenerateMatchupImage
590
+ };
591
+