@devrongx/games 0.1.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.
@@ -0,0 +1,604 @@
1
+ // @devrongx/games — games/prematch-bets/config.ts
2
+ // Pure types, config, and calculation functions for the IPL prediction betting system.
3
+ // Everything is driven by IChallengeConfig — the single JSON object that will come from APIs later.
4
+ // Leaderboard pool is generated deterministically from a seed string via seedrandom.
5
+
6
+ import seedrandom from "seedrandom";
7
+
8
+ // ────── Types ──────
9
+
10
+ export interface IChallengeOption {
11
+ label: string;
12
+ odds: number; // Decimal odds (e.g., 1.40 = 40% profit, 3.2 = 220% profit)
13
+ entry: number; // Points to bet on this option
14
+ risk: number; // Admin-set risk contribution 0–100. Lower odds → lower risk. Weighted by bet amount.
15
+ }
16
+
17
+ export interface IChallengeMarket {
18
+ id: string;
19
+ question: string;
20
+ icon: string;
21
+ accent: string;
22
+ category: string; // "toss" | "match" | "csk_innings" | "rcb_innings" | "match_level"
23
+ options: IChallengeOption[];
24
+ userBet?: IBetEntry; // Present when API returns a logged-in user's saved bet for this market
25
+ }
26
+
27
+ export interface IChallengeTeam {
28
+ name: string; // "Chennai"
29
+ shortName: string; // "CSK"
30
+ logo: string; // "/ipl/csk.png"
31
+ gradient: string; // "linear-gradient(180deg, #FFF02C 0%, #DF9901 100%)"
32
+ }
33
+
34
+ export interface IChallengeGame {
35
+ id: string; // Popup/route identifier ("pre-match", "ball-sequence", "fantasy-11")
36
+ title: string; // Card heading ("Pre-Match Bets")
37
+ desc: string; // Card description
38
+ tag: string; // Badge label ("STRATEGY", "LIVE ACTION", "SKILL BASED")
39
+ icon: string; // Key into a UI-side icon map ("stadium", "ball", "fantasy")
40
+ }
41
+
42
+ export interface IChallengeSection {
43
+ type: "info" | "challenge";
44
+ title: string;
45
+ content: string;
46
+ }
47
+
48
+ export interface IRankBracket {
49
+ from: number; // Inclusive start rank
50
+ to: number; // Inclusive end rank
51
+ poolPercent: number; // Total % of prize pool shared equally among ranks in this bracket
52
+ }
53
+
54
+ export interface IChallengeConfig {
55
+ id: string;
56
+ title: string;
57
+ matchTitle: string;
58
+ tag: string;
59
+ teamA: IChallengeTeam;
60
+ teamB: IChallengeTeam;
61
+ venue: string; // "M. Chinnaswamy Stadium, Bengaluru"
62
+ bannerImage: string; // Match-specific background image path
63
+ playerImages: [string, string]; // [player1, player2] images for banner
64
+ opponents: IChallengeTeam[]; // Cycling opponent list for homepage animation
65
+ matchStartTime: string; // ISO 8601 datetime for match start
66
+ startingBalance: number;
67
+ entryFee: number; // USDC entry fee per player
68
+ totalEntries: number; // Total players in the pool (drives prize pool calc)
69
+ rakePercent: number; // Platform cut, 0–1 (e.g., 0.20 = 20%)
70
+ rankBrackets: IRankBracket[]; // Prize pool distribution by rank bracket
71
+ leaderboardSeed: string; // Deterministic seed for fake pool generation
72
+ compoundMultipliers: number[]; // Index = number of active bets
73
+ parlayConfig: IParlayConfig; // Combined bet configuration
74
+ categoryLabels: Record<string, string>; // Display names for market categories (keyed by market.category)
75
+ games: IChallengeGame[]; // Game modes for this match (cards on homepage)
76
+ markets: IChallengeMarket[];
77
+ sections: IChallengeSection[];
78
+ }
79
+
80
+ export interface ILeaderboardEntry {
81
+ wallet: string;
82
+ pts: number;
83
+ payout: number;
84
+ isYou: boolean;
85
+ rank?: number; // Actual rank among totalEntries (pool-based leaderboards)
86
+ gapAbove?: boolean; // Visual separator — there are hidden ranks above this row
87
+ }
88
+
89
+ export interface IParlayConfig {
90
+ maxSlots: number; // Max combined bet groups (6)
91
+ minLegs: number; // Minimum questions to combine (2)
92
+ maxLegs: number; // Max questions per parlay
93
+ colors: string[]; // Distinct color per slot
94
+ defaultStake: number; // Default points per parlay leg
95
+ stakeIncrements: number; // Picker step size
96
+ }
97
+
98
+ // ── Unified bet state ──
99
+ // Single source of truth for all user selections: option picks, bet amounts, and parlay grouping.
100
+ // parlaySlot = null means individual bet. parlaySlot = 0–5 means part of that combined bet group.
101
+ export interface IBetEntry {
102
+ optionIdx: number;
103
+ amount: number; // 0 = option picked but no points committed yet
104
+ parlaySlot: number | null; // null = individual, 0–5 = combined bet group
105
+ }
106
+ export type IUserBets = Record<number, IBetEntry>; // mIdx → entry
107
+
108
+ // ── Derived helpers (computed from IUserBets, not stored) ──
109
+
110
+ /** Extract parlay groups: slot → [mIdx, ...]. Only includes slots with entries. */
111
+ export function deriveParlayGroups(bets: IUserBets): Record<number, number[]> {
112
+ const groups: Record<number, number[]> = {};
113
+ for (const [mIdxStr, entry] of Object.entries(bets)) {
114
+ if (entry.parlaySlot !== null) {
115
+ (groups[entry.parlaySlot] ??= []).push(Number(mIdxStr));
116
+ }
117
+ }
118
+ return groups;
119
+ }
120
+
121
+ /** Inverse lookup: mIdx → parlaySlot (only for markets actually in a parlay). */
122
+ export function deriveMarketToParlay(bets: IUserBets): Record<number, number> {
123
+ const map: Record<number, number> = {};
124
+ for (const [mIdxStr, entry] of Object.entries(bets)) {
125
+ if (entry.parlaySlot !== null) map[Number(mIdxStr)] = entry.parlaySlot;
126
+ }
127
+ return map;
128
+ }
129
+
130
+ /** Initialize local bet state from config — picks up any saved user bets the API returned inline on markets. */
131
+ export function extractUserBets(config: IChallengeConfig): IUserBets {
132
+ const bets: IUserBets = {};
133
+ config.markets.forEach((m, i) => {
134
+ if (m.userBet) bets[i] = m.userBet;
135
+ });
136
+ return bets;
137
+ }
138
+
139
+ export interface IBetSummary {
140
+ selectedCount: number;
141
+ compoundMultiplier: number;
142
+ totalEntry: number;
143
+ baseReward: number;
144
+ compoundedReward: number;
145
+ remainingBalance: number;
146
+ potentialBalance: number;
147
+ riskPercent: number; // Weighted risk 0–100 from config risk values
148
+ }
149
+
150
+ export interface IMiniLeaderboard {
151
+ rank: number;
152
+ you: ILeaderboardEntry;
153
+ above: ILeaderboardEntry | null;
154
+ below: ILeaderboardEntry | null;
155
+ aboveRank: number;
156
+ belowRank: number;
157
+ total: number;
158
+ }
159
+
160
+ // ────── Pool types ──────
161
+
162
+ export interface IPoolData {
163
+ scores: number[]; // Sorted descending, length = totalEntries - 1
164
+ seed: string; // For deterministic wallet generation
165
+ }
166
+
167
+ // ────── Challenge Config: CSK vs RCB ──────
168
+ // This entire object will come from the API later. For now it's hardcoded.
169
+
170
+ export const CSK_VS_RCB_CHALLENGE: IChallengeConfig = {
171
+ id: "csk-vs-rcb-2026-match1",
172
+ title: "Pre-Match Bets",
173
+ matchTitle: "Chennai vs Bengaluru",
174
+ tag: "STRATEGY",
175
+ teamA: { name: "Chennai", shortName: "CSK", logo: "/ipl/csk.png", gradient: "linear-gradient(180deg, #FFF02C 0%, #DF9901 100%)" },
176
+ teamB: { name: "Bengaluru", shortName: "RCB", logo: "/ipl/rcb.png", gradient: "linear-gradient(180deg, #191919 0%, #DB0102 100%)" },
177
+ venue: "M. Chinnaswamy Stadium, Bengaluru",
178
+ bannerImage: "/ipl/ipl-pred-bg.png",
179
+ playerImages: ["/ipl/ipl-pred-player1.png", "/ipl/ipl-pred-player2.png"],
180
+ opponents: [
181
+ { name: "Chennai", shortName: "CSK", logo: "/ipl/csk.png", gradient: "linear-gradient(180deg, #FFF02C 0%, #DF9901 100%)" },
182
+ { name: "Kolkata", shortName: "KKR", logo: "/ipl/kkr.png", gradient: "linear-gradient(180deg, #9354E5 0%, #2B0B79 100%)" },
183
+ { name: "Mumbai", shortName: "MI", logo: "/ipl/mi.png", gradient: "linear-gradient(180deg, #3398F3 0%, #002DAA 100%)" },
184
+ { name: "Hyderabad", shortName: "SRH", logo: "/ipl/srh.png", gradient: "linear-gradient(180deg, #FFBB00 0%, #FF0003 100%)" },
185
+ { name: "Delhi", shortName: "DC", logo: "/ipl/dc.png", gradient: "linear-gradient(180deg, #ED1B1E 0%, #0E39B4 100%)" },
186
+ { name: "Punjab", shortName: "PBKS", logo: "/ipl/pbks.png", gradient: "linear-gradient(180deg, #FF5356 0%, #FF0003 100%)" },
187
+ { name: "Gujarat", shortName: "GT", logo: "/ipl/gt.png", gradient: "linear-gradient(180deg, #2F5385 0%, #11224D 100%)" },
188
+ { name: "Lucknow", shortName: "LSG", logo: "/ipl/lsg.png", gradient: "linear-gradient(180deg, #FF0003 0%, #030E4A 87.98%)" },
189
+ { name: "Rajasthan", shortName: "RR", logo: "/ipl/rr.png", gradient: "linear-gradient(180deg, #E94098 0%, #B60153 100%)" },
190
+ ],
191
+ matchStartTime: "2026-03-28T19:30:00+05:30",
192
+ startingBalance: 2500,
193
+ entryFee: 1,
194
+ totalEntries: 20000,
195
+ rakePercent: 0.20,
196
+ leaderboardSeed: "csk-vs-rcb-2026-iamgame-seed-v1",
197
+
198
+ rankBrackets: [
199
+ { from: 1, to: 1, poolPercent: 0.20 },
200
+ { from: 2, to: 2, poolPercent: 0.10 },
201
+ { from: 3, to: 3, poolPercent: 0.05 },
202
+ { from: 4, to: 5, poolPercent: 0.04 },
203
+ { from: 6, to: 10, poolPercent: 0.05 },
204
+ { from: 11, to: 25, poolPercent: 0.06 },
205
+ { from: 26, to: 75, poolPercent: 0.08 },
206
+ { from: 76, to: 200, poolPercent: 0.09 },
207
+ { from: 201, to: 500, poolPercent: 0.08 },
208
+ { from: 501, to: 1500, poolPercent: 0.09 },
209
+ { from: 1501, to: 4000, poolPercent: 0.10 },
210
+ { from: 4001, to: 6000, poolPercent: 0.06 },
211
+ ],
212
+
213
+ parlayConfig: {
214
+ maxSlots: 6,
215
+ minLegs: 2,
216
+ maxLegs: 14,
217
+ colors: ["#22E3E8", "#9945FF", "#f83cc5", "#22C55E", "#f59e0b", "#3b82f6"],
218
+ defaultStake: 100,
219
+ stakeIncrements: 50,
220
+ },
221
+
222
+ compoundMultipliers: [
223
+ 0, 1, 1.6, 2.5, 4, 6.5, 10, 16, 27, 43, 70, 110, 175, 280, 450,
224
+ ],
225
+
226
+ categoryLabels: {
227
+ toss: "Toss",
228
+ match: "Match Result",
229
+ csk_innings: "Chennai Innings",
230
+ rcb_innings: "Bengaluru Innings",
231
+ match_level: "Match Level",
232
+ },
233
+
234
+ games: [
235
+ {
236
+ id: "pre-match",
237
+ title: "Pre-Match Bets",
238
+ desc: "Toss, winner, top scorers, boundaries, wickets, MOTM. Lock in before ball one.",
239
+ tag: "STRATEGY",
240
+ icon: "stadium",
241
+ },
242
+ {
243
+ id: "ball-sequence",
244
+ title: "Ball Sequence",
245
+ desc: "Predict every delivery live — dot, four, six, wicket, no-ball. Bet ball-by-ball, win ball-by-ball.",
246
+ tag: "LIVE ACTION",
247
+ icon: "ball",
248
+ },
249
+ {
250
+ id: "fantasy-11",
251
+ title: "Fantasy 11 Team",
252
+ desc: "Draft your playing XI before the lineup drops. Outscore every other squad on the leaderboard.",
253
+ tag: "SKILL BASED",
254
+ icon: "fantasy",
255
+ },
256
+ ],
257
+
258
+ sections: [
259
+ {
260
+ type: "info",
261
+ title: "How It Works",
262
+ content: "Pick your predictions across 14 markets. Each correct pick earns points based on the odds. Stack picks for exponential parlay multipliers \u2014 up to 450x on a full sweep. Top the leaderboard, take home the prize pool.",
263
+ },
264
+ {
265
+ type: "challenge",
266
+ title: "CSK vs RCB — H2H Stats",
267
+ content: "Based on last 5 head-to-head matches. RCB leads 3-2. Average combined score: 372 runs. RCB won toss 3 out of 5 times.",
268
+ },
269
+ ],
270
+
271
+ markets: [
272
+ { id: "toss", question: "Who wins the toss?", icon: "coin", accent: "#22E3E8", category: "toss", options: [
273
+ { label: "Chennai", odds: 1.40, entry: 50, risk: 0 },
274
+ { label: "Bengaluru", odds: 1.40, entry: 50, risk: 0 },
275
+ ]},
276
+ { id: "match_result", question: "Who wins the match?", icon: "trophy", accent: "#9945FF", category: "match", options: [
277
+ { label: "Chennai", odds: 2.05, entry: 50, risk: 20 },
278
+ { label: "Bengaluru", odds: 1.75, entry: 50, risk: 12 },
279
+ ]},
280
+ { id: "csk_top_scorer", question: "Highest run scorer \u2014 Chennai?", icon: "bat", accent: "#f83cc5", category: "csk_innings", options: [
281
+ { label: "R Gaikwad", odds: 3.2, entry: 35, risk: 38 },
282
+ { label: "S Samson", odds: 3.8, entry: 30, risk: 45 },
283
+ { label: "S Dube", odds: 4.5, entry: 25, risk: 55 },
284
+ { label: "MS Dhoni", odds: 7.5, entry: 20, risk: 75 },
285
+ { label: "D Brevis", odds: 8.0, entry: 20, risk: 78 },
286
+ { label: "Others", odds: 10.0, entry: 15, risk: 85 },
287
+ ]},
288
+ { id: "csk_fours", question: "Total 4s \u2014 Chennai innings?", icon: "four", accent: "#22E3E8", category: "csk_innings", options: [
289
+ { label: "<10", odds: 2.6, entry: 30, risk: 30 },
290
+ { label: "10\u201314", odds: 1.9, entry: 40, risk: 15 },
291
+ { label: "15\u201318", odds: 1.6, entry: 45, risk: 8 },
292
+ { label: "19+", odds: 2.4, entry: 35, risk: 25 },
293
+ ]},
294
+ { id: "csk_sixes", question: "Total 6s \u2014 Chennai innings?", icon: "six", accent: "#9945FF", category: "csk_innings", options: [
295
+ { label: "<8", odds: 2.4, entry: 30, risk: 25 },
296
+ { label: "8\u201312", odds: 1.8, entry: 40, risk: 12 },
297
+ { label: "13\u201316", odds: 1.7, entry: 40, risk: 10 },
298
+ { label: "17+", odds: 2.8, entry: 25, risk: 35 },
299
+ ]},
300
+ { id: "csk_top_wicket", question: "Highest wicket-taker \u2014 Chennai?", icon: "target", accent: "#f83cc5", category: "csk_innings", options: [
301
+ { label: "Noor", odds: 3.4, entry: 30, risk: 40 },
302
+ { label: "R Chahar", odds: 3.6, entry: 30, risk: 42 },
303
+ { label: "Ellis", odds: 4.2, entry: 25, risk: 52 },
304
+ { label: "Others", odds: 5.0, entry: 20, risk: 60 },
305
+ ]},
306
+ { id: "rcb_top_scorer", question: "Highest run scorer \u2014 Bengaluru?", icon: "bat", accent: "#22E3E8", category: "rcb_innings", options: [
307
+ { label: "V Kohli", odds: 3.0, entry: 35, risk: 35 },
308
+ { label: "R Patidar", odds: 3.5, entry: 30, risk: 42 },
309
+ { label: "J Sharma", odds: 4.2, entry: 25, risk: 52 },
310
+ { label: "Others", odds: 9.0, entry: 15, risk: 82 },
311
+ ]},
312
+ { id: "rcb_fours", question: "Total 4s \u2014 Bengaluru innings?", icon: "four", accent: "#9945FF", category: "rcb_innings", options: [
313
+ { label: "<10", odds: 2.5, entry: 30, risk: 28 },
314
+ { label: "10\u201314", odds: 1.9, entry: 40, risk: 15 },
315
+ { label: "15\u201318", odds: 1.6, entry: 45, risk: 8 },
316
+ { label: "19+", odds: 2.5, entry: 30, risk: 28 },
317
+ ]},
318
+ { id: "rcb_sixes", question: "Total 6s \u2014 Bengaluru innings?", icon: "six", accent: "#f83cc5", category: "rcb_innings", options: [
319
+ { label: "<9", odds: 2.3, entry: 30, risk: 22 },
320
+ { label: "9\u201313", odds: 1.8, entry: 40, risk: 12 },
321
+ { label: "14\u201317", odds: 1.7, entry: 40, risk: 10 },
322
+ { label: "18+", odds: 2.9, entry: 25, risk: 35 },
323
+ ]},
324
+ { id: "rcb_top_wicket", question: "Highest wicket-taker \u2014 Bengaluru?", icon: "target", accent: "#22E3E8", category: "rcb_innings", options: [
325
+ { label: "Hazlewood", odds: 3.3, entry: 30, risk: 38 },
326
+ { label: "Noor", odds: 4.0, entry: 25, risk: 48 },
327
+ { label: "Bhuvneshwar", odds: 4.5, entry: 25, risk: 55 },
328
+ { label: "Others", odds: 6.0, entry: 20, risk: 65 },
329
+ ]},
330
+ { id: "combined_score", question: "Combined match score?", icon: "chart", accent: "#9945FF", category: "match_level", options: [
331
+ { label: "<300", odds: 3.2, entry: 25, risk: 38 },
332
+ { label: "300\u2013350", odds: 2.3, entry: 35, risk: 22 },
333
+ { label: "351\u2013400", odds: 1.7, entry: 45, risk: 10 },
334
+ { label: "401+", odds: 3.6, entry: 25, risk: 42 },
335
+ ]},
336
+ { id: "total_fours_match", question: "Total 4s in match?", icon: "four", accent: "#f83cc5", category: "match_level", options: [
337
+ { label: "<20", odds: 2.8, entry: 30, risk: 32 },
338
+ { label: "20\u201330", odds: 1.9, entry: 40, risk: 15 },
339
+ { label: "31\u201340", odds: 1.6, entry: 45, risk: 8 },
340
+ { label: "41+", odds: 2.7, entry: 30, risk: 30 },
341
+ ]},
342
+ { id: "total_sixes_match", question: "Total 6s in match?", icon: "six", accent: "#22E3E8", category: "match_level", options: [
343
+ { label: "<18", odds: 2.5, entry: 30, risk: 28 },
344
+ { label: "18\u201325", odds: 1.8, entry: 40, risk: 12 },
345
+ { label: "26\u201335", odds: 1.7, entry: 40, risk: 10 },
346
+ { label: "36+", odds: 3.0, entry: 25, risk: 35 },
347
+ ]},
348
+ { id: "potm", question: "Player of the Match?", icon: "star", accent: "#9945FF", category: "match_level", options: [
349
+ { label: "V Kohli", odds: 4.0, entry: 30, risk: 48 },
350
+ { label: "R Gaikwad", odds: 4.5, entry: 25, risk: 55 },
351
+ { label: "S Samson", odds: 5.0, entry: 25, risk: 60 },
352
+ { label: "C Overton", odds: 6.0, entry: 20, risk: 65 },
353
+ ]},
354
+ ],
355
+ };
356
+
357
+ // ────── Pool generation (seedrandom) ──────
358
+
359
+ const SCORE_QUANTILES: [number, number, number][] = [
360
+ [0.300, 700, 999],
361
+ [0.800, 1000, 1574],
362
+ [0.875, 1575, 2282],
363
+ [0.930, 2283, 3625],
364
+ [0.965, 3626, 6134],
365
+ [0.9825, 6135, 10995],
366
+ [0.9915, 10996, 20041],
367
+ [0.9965, 20042, 34434],
368
+ [0.99875, 34435, 62529],
369
+ [0.99960, 62530, 106664],
370
+ [0.99980, 106665, 183994],
371
+ [0.99990, 183995, 315004],
372
+ [1.0, 315005, 559884],
373
+ ];
374
+
375
+ function generateScore(rng: () => number): number {
376
+ const u = rng();
377
+ for (const [cumProb, min, max] of SCORE_QUANTILES) {
378
+ if (u < cumProb) return Math.round(min + rng() * (max - min));
379
+ }
380
+ return SCORE_QUANTILES[SCORE_QUANTILES.length - 1][2];
381
+ }
382
+
383
+ export function generatePool(config: IChallengeConfig): IPoolData {
384
+ const rng = seedrandom(config.leaderboardSeed);
385
+ const scores: number[] = [];
386
+ for (let i = 0; i < config.totalEntries - 1; i++) {
387
+ scores.push(generateScore(rng));
388
+ }
389
+ scores.sort((a, b) => b - a);
390
+ return { scores, seed: config.leaderboardSeed };
391
+ }
392
+
393
+ function walletForIndex(seed: string, index: number): string {
394
+ const rng = seedrandom(`${seed}-w-${index}`);
395
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz123456789";
396
+ const pick = () => chars[Math.floor(rng() * chars.length)];
397
+ return `${pick()}${pick()}${pick()}${pick()}..${pick()}${pick()}${pick()}${pick()}`;
398
+ }
399
+
400
+ export const CSK_VS_RCB_POOL = generatePool(CSK_VS_RCB_CHALLENGE);
401
+
402
+ // ────── Calculations ──────
403
+
404
+ export function optionReward(opt: IChallengeOption): number {
405
+ return Math.round(opt.entry * opt.odds);
406
+ }
407
+
408
+ export function calcBetSummary(
409
+ bets: IUserBets,
410
+ config: IChallengeConfig,
411
+ ): IBetSummary {
412
+ const { markets, startingBalance } = config;
413
+ const selectedCount = Object.keys(bets).length;
414
+ const compoundMultiplier = config.compoundMultipliers[selectedCount] ?? 1;
415
+
416
+ const parlayGroups = deriveParlayGroups(bets);
417
+
418
+ const activeSlots = new Set<number>();
419
+ for (const [slot, legs] of Object.entries(parlayGroups)) {
420
+ if (legs.length >= 2) activeSlots.add(Number(slot));
421
+ }
422
+
423
+ let totalEntry = 0;
424
+ let baseReward = 0;
425
+ let weightedRiskSum = 0;
426
+
427
+ for (const [mIdxStr, { optionIdx, amount, parlaySlot }] of Object.entries(bets)) {
428
+ const mIdx = Number(mIdxStr);
429
+ const opt = markets[mIdx].options[optionIdx];
430
+ totalEntry += amount;
431
+ weightedRiskSum += amount * opt.risk;
432
+
433
+ const isInActiveParlay = parlaySlot !== null && activeSlots.has(parlaySlot);
434
+ if (!isInActiveParlay) {
435
+ baseReward += Math.round(amount * opt.odds);
436
+ }
437
+ }
438
+
439
+ for (const slot of activeSlots) {
440
+ const legs = parlayGroups[slot];
441
+ let combinedOdds = 1;
442
+ let parlayStake = 0;
443
+ for (const mIdx of legs) {
444
+ const bet = bets[mIdx];
445
+ const opt = markets[mIdx].options[bet.optionIdx];
446
+ combinedOdds *= opt.odds;
447
+ parlayStake += bet.amount;
448
+ }
449
+ baseReward += Math.round(parlayStake * combinedOdds);
450
+ }
451
+
452
+ const compoundedReward = Math.round(baseReward * compoundMultiplier);
453
+ const remainingBalance = startingBalance - totalEntry;
454
+ const potentialBalance = remainingBalance + compoundedReward;
455
+ const riskPercent = totalEntry > 0
456
+ ? Math.min(Math.round(weightedRiskSum / totalEntry), 100)
457
+ : 0;
458
+
459
+ return {
460
+ selectedCount,
461
+ compoundMultiplier,
462
+ totalEntry,
463
+ baseReward,
464
+ compoundedReward,
465
+ remainingBalance,
466
+ potentialBalance,
467
+ riskPercent,
468
+ };
469
+ }
470
+
471
+ export function calcParlayMultiplier(
472
+ slot: number,
473
+ bets: IUserBets,
474
+ config: IChallengeConfig,
475
+ ): { multiplier: number; totalStake: number; breakdown: { question: string; odds: number }[] } {
476
+ const breakdown: { question: string; odds: number }[] = [];
477
+ let multiplier = 1;
478
+ let totalStake = 0;
479
+ for (const [mIdxStr, entry] of Object.entries(bets)) {
480
+ if (entry.parlaySlot !== slot) continue;
481
+ const mIdx = Number(mIdxStr);
482
+ const market = config.markets[mIdx];
483
+ const opt = market.options[entry.optionIdx];
484
+ multiplier *= opt.odds;
485
+ totalStake += entry.amount;
486
+ breakdown.push({ question: market.question, odds: opt.odds });
487
+ }
488
+ return { multiplier: Math.round(multiplier * 100) / 100, totalStake, breakdown };
489
+ }
490
+
491
+ export function calcPrizePool(config: IChallengeConfig): number {
492
+ return config.entryFee * config.totalEntries * (1 - config.rakePercent);
493
+ }
494
+
495
+ export function calcRankPayout(rank: number, config: IChallengeConfig): number {
496
+ const bracket = config.rankBrackets.find(b => rank >= b.from && rank <= b.to);
497
+ if (!bracket) return 0;
498
+ const pool = calcPrizePool(config);
499
+ const ranksInBracket = bracket.to - bracket.from + 1;
500
+ const perRank = (bracket.poolPercent / ranksInBracket) * pool;
501
+ return Math.round(perRank * 10) / 10;
502
+ }
503
+
504
+ // ────── Rank finding (binary search) ──────
505
+
506
+ function findUserRank(pool: IPoolData, userPts: number): number {
507
+ const scores = pool.scores;
508
+ let lo = 0, hi = scores.length;
509
+ while (lo < hi) {
510
+ const mid = (lo + hi) >> 1;
511
+ if (scores[mid] > userPts) lo = mid + 1;
512
+ else hi = mid;
513
+ }
514
+ return lo + 1;
515
+ }
516
+
517
+ function poolEntryAtRank(
518
+ pool: IPoolData,
519
+ rank: number,
520
+ userRank: number,
521
+ config: IChallengeConfig,
522
+ ): ILeaderboardEntry {
523
+ const idx = rank < userRank ? rank - 1 : rank - 2;
524
+ return {
525
+ wallet: walletForIndex(pool.seed, idx),
526
+ pts: pool.scores[idx],
527
+ payout: calcRankPayout(rank, config),
528
+ isYou: false,
529
+ rank,
530
+ };
531
+ }
532
+
533
+ // ────── Visible leaderboard builder ──────
534
+
535
+ export function buildPoolLeaderboard(
536
+ pool: IPoolData,
537
+ userPts: number,
538
+ config: IChallengeConfig,
539
+ ): { rows: ILeaderboardEntry[]; mini: IMiniLeaderboard } {
540
+ const totalPlayers = config.totalEntries;
541
+ const userRank = findUserRank(pool, userPts);
542
+
543
+ const rankSet = new Set<number>();
544
+ for (let r = 1; r <= Math.min(3, totalPlayers); r++) rankSet.add(r);
545
+ for (let r = Math.max(1, userRank - 3); r <= Math.min(totalPlayers, userRank + 3); r++) {
546
+ rankSet.add(r);
547
+ }
548
+ const visibleRanks = [...rankSet].sort((a, b) => a - b);
549
+
550
+ const rows: ILeaderboardEntry[] = [];
551
+ for (let i = 0; i < visibleRanks.length; i++) {
552
+ const rank = visibleRanks[i];
553
+ const gapAbove = i > 0 && visibleRanks[i - 1] < rank - 1;
554
+
555
+ if (rank === userRank) {
556
+ rows.push({
557
+ wallet: "You",
558
+ pts: userPts,
559
+ payout: calcRankPayout(rank, config),
560
+ isYou: true,
561
+ rank,
562
+ gapAbove,
563
+ });
564
+ } else {
565
+ const entry = poolEntryAtRank(pool, rank, userRank, config);
566
+ entry.gapAbove = gapAbove;
567
+ rows.push(entry);
568
+ }
569
+ }
570
+
571
+ const youEntry = rows.find(e => e.isYou)!;
572
+ const above = userRank > 1
573
+ ? poolEntryAtRank(pool, userRank - 1, userRank, config)
574
+ : null;
575
+ const below = userRank < totalPlayers
576
+ ? poolEntryAtRank(pool, userRank + 1, userRank, config)
577
+ : null;
578
+
579
+ const mini: IMiniLeaderboard = {
580
+ rank: userRank,
581
+ you: youEntry,
582
+ above,
583
+ below,
584
+ aboveRank: userRank - 1,
585
+ belowRank: userRank + 1,
586
+ total: totalPlayers,
587
+ };
588
+
589
+ return { rows, mini };
590
+ }
591
+
592
+ export function calcDisplayReward(
593
+ opt: IChallengeOption,
594
+ compoundMultiplier: number,
595
+ isSelected: boolean,
596
+ selectedCount: number,
597
+ betAmount?: number,
598
+ ): number {
599
+ const base = betAmount != null ? Math.round(betAmount * opt.odds) : optionReward(opt);
600
+ if (isSelected && selectedCount >= 2) {
601
+ return Math.round(base * compoundMultiplier);
602
+ }
603
+ return base;
604
+ }
@@ -0,0 +1,33 @@
1
+ // @devrongx/games — games/prematch-bets/constants.tsx
2
+ // Shared constants and micro-components for IPL betting UI.
3
+
4
+ import Image from "next/image";
5
+ import { Coins, Trophy, Swords, Square, Zap, Target, BarChart3, Star } from "lucide-react";
6
+
7
+ /** Font style applied to all betting UI text */
8
+ export const OUTFIT = { fontFamily: "Outfit, sans-serif" } as const;
9
+
10
+ /** Icon map keyed by config string — shared across market renderers */
11
+ export const MARKET_ICONS: Record<string, typeof Coins> = {
12
+ coin: Coins,
13
+ trophy: Trophy,
14
+ bat: Swords,
15
+ four: Square,
16
+ six: Zap,
17
+ target: Target,
18
+ chart: BarChart3,
19
+ star: Star,
20
+ };
21
+
22
+ /** Points logo at a given size */
23
+ export const PointsIcon = ({ size = 7, className = "" }: { size?: number; className?: string }) => (
24
+ <Image src="/iamgame_square_logo.jpg" alt="" width={size} height={size} className={`rounded-[${Math.max(1, Math.round(size / 5))}px] ${className}`} />
25
+ );
26
+
27
+ /** Purple checkmark inside white circle — used for selected options */
28
+ export const SelectedCheck = ({ size = 10 }: { size?: number }) => (
29
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
30
+ <circle cx="8" cy="8" r="8" fill="white" />
31
+ <path d="M5 8l2 2 4-4" stroke="#9945FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
32
+ </svg>
33
+ );
@@ -0,0 +1,4 @@
1
+ // @devrongx/games — games/prematch-bets barrel
2
+ export { PreMatchBetsPopup } from "./PreMatchBetsPopup";
3
+ export { CSK_VS_RCB_CHALLENGE, CSK_VS_RCB_POOL, calcBetSummary, buildPoolLeaderboard, generatePool, calcPrizePool, calcRankPayout, calcParlayMultiplier, calcDisplayReward, optionReward, deriveParlayGroups, deriveMarketToParlay, extractUserBets } from "./config";
4
+ export type { IChallengeConfig, IChallengeMarket, IChallengeOption, IChallengeTeam, IChallengeGame, IChallengeSection, IRankBracket, IParlayConfig, IUserBets, IBetEntry, IBetSummary, ILeaderboardEntry, IMiniLeaderboard, IPoolData, ISelectedBets, IParlayState } from "./config";
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // @devrongx/games — top-level barrel
2
+ // Core infrastructure
3
+ export { GamePopupShell } from "./core/GamePopupShell";
4
+ export { useGamePopupStore } from "./core/gamePopupStore";
5
+ export type { IGamePopupState } from "./core/types";
6
+
7
+ // Games — Pre-Match Bets
8
+ export { PreMatchBetsPopup } from "./games/prematch-bets/PreMatchBetsPopup";
9
+ export { CSK_VS_RCB_CHALLENGE, CSK_VS_RCB_POOL, calcBetSummary, buildPoolLeaderboard, generatePool, calcPrizePool, calcRankPayout, calcParlayMultiplier, calcDisplayReward, optionReward, deriveParlayGroups, deriveMarketToParlay, extractUserBets } from "./games/prematch-bets/config";
10
+ export type { IChallengeConfig, IChallengeMarket, IChallengeOption, IChallengeTeam, IChallengeGame, IChallengeSection, IRankBracket, IParlayConfig, IUserBets, IBetEntry, IBetSummary, ILeaderboardEntry, IMiniLeaderboard, IPoolData } from "./games/prematch-bets/config";
@@ -0,0 +1,16 @@
1
+ /* @devrongx/games — animations.css */
2
+ /* Game-specific CSS keyframes and utility classes */
3
+
4
+ @keyframes textFillSweep {
5
+ 0% { background-position: 100% 0; }
6
+ 100% { background-position: 0% 0; }
7
+ }
8
+
9
+ @keyframes shine {
10
+ 0% { background-position: -200% center; }
11
+ 100% { background-position: 200% center; }
12
+ }
13
+
14
+ .animate-shine {
15
+ animation: shine 2s ease-in-out infinite;
16
+ }