git-watchtower 1.6.1 → 1.7.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,721 @@
1
+ /**
2
+ * Casino Mode - Vegas-style feedback for git-watchtower
3
+ *
4
+ * Adds slot machine animations, marquee lights, win celebrations,
5
+ * and gamification stats to make waiting for CI/AI updates feel
6
+ * like hitting the jackpot.
7
+ */
8
+
9
+ const { ansi, box } = require('../ui/ansi');
10
+
11
+ // ============================================================================
12
+ // Casino Mode State
13
+ // ============================================================================
14
+
15
+ let casinoEnabled = false;
16
+ let casinoStats = {
17
+ totalLinesAdded: 0,
18
+ totalLinesDeleted: 0,
19
+ consecutivePolls: 0,
20
+ pollsWithUpdates: 0,
21
+ bigWins: 0, // 500+ line changes
22
+ jackpots: 0, // 1000+ line changes
23
+ megaJackpots: 0, // 5000+ line changes
24
+ sessionStart: Date.now(),
25
+ totalPolls: 0, // Total lever pulls
26
+ nearMisses: 0, // Polls with no changes
27
+ lastHitTime: null, // Timestamp of last update
28
+ };
29
+
30
+ // Marquee animation state
31
+ let marqueeFrame = 0;
32
+ let marqueeInterval = null;
33
+
34
+ // Slot reel animation state
35
+ let slotReelFrame = 0;
36
+ let slotReelInterval = null;
37
+ let isSpinning = false;
38
+ let slotResult = null; // Final symbols to display
39
+ let slotResultIsWin = false; // Whether result was a win
40
+ let slotResultFlashFrame = 0; // Flash animation frame
41
+ let slotResultInterval = null; // Interval for result display/flash
42
+ let slotResultRenderCallback = null; // Callback for re-rendering
43
+ let slotResultLabel = null; // "NOTHING", "WIN", "BIG WIN", "JACKPOT" etc
44
+
45
+ // Win animation state
46
+ let winAnimationFrame = 0;
47
+ let winAnimationInterval = null;
48
+ let currentWinLevel = null;
49
+
50
+ // ============================================================================
51
+ // Configuration
52
+ // ============================================================================
53
+
54
+ const SLOT_SYMBOLS = ['🍒', '🍋', '🍊', '🍇', '🔔', '💎', '7️⃣', '🎰'];
55
+ const MARQUEE_CHARS = ['◆', '◇', '●', '○', '★', '☆'];
56
+ const MARQUEE_COLORS = [
57
+ ansi.brightRed,
58
+ ansi.brightYellow,
59
+ ansi.brightGreen,
60
+ ansi.brightCyan,
61
+ ansi.brightBlue,
62
+ ansi.brightMagenta,
63
+ ];
64
+
65
+ // Win level thresholds (lines added + deleted)
66
+ const WIN_LEVELS = {
67
+ small: { min: 1, max: 49, label: 'WIN', color: ansi.green },
68
+ medium: { min: 50, max: 199, label: 'NICE WIN!', color: ansi.yellow },
69
+ large: { min: 200, max: 499, label: 'BIG WIN!', color: ansi.brightYellow },
70
+ huge: { min: 500, max: 999, label: 'HUGE WIN!', color: ansi.brightMagenta },
71
+ jackpot: { min: 1000, max: 4999, label: '💰 JACKPOT! 💰', color: ansi.brightCyan },
72
+ mega: { min: 5000, max: Infinity, label: '🎰 MEGA JACKPOT!!! 🎰', color: ansi.brightRed },
73
+ };
74
+
75
+ // ============================================================================
76
+ // Mode Control
77
+ // ============================================================================
78
+
79
+ /**
80
+ * Check if casino mode is enabled
81
+ * @returns {boolean}
82
+ */
83
+ function isEnabled() {
84
+ return casinoEnabled;
85
+ }
86
+
87
+ /**
88
+ * Enable casino mode
89
+ */
90
+ function enable() {
91
+ casinoEnabled = true;
92
+ startMarquee();
93
+ }
94
+
95
+ /**
96
+ * Disable casino mode
97
+ */
98
+ function disable() {
99
+ casinoEnabled = false;
100
+ stopMarquee();
101
+ stopSlotReels();
102
+ stopWinAnimation();
103
+ }
104
+
105
+ /**
106
+ * Toggle casino mode
107
+ * @returns {boolean} New state
108
+ */
109
+ function toggle() {
110
+ if (casinoEnabled) {
111
+ disable();
112
+ } else {
113
+ enable();
114
+ }
115
+ return casinoEnabled;
116
+ }
117
+
118
+ // ============================================================================
119
+ // Marquee Border Animation
120
+ // ============================================================================
121
+
122
+ let marqueeCallback = null;
123
+
124
+ /**
125
+ * Set the render callback for marquee updates
126
+ * @param {Function} callback
127
+ */
128
+ function setRenderCallback(callback) {
129
+ marqueeCallback = callback;
130
+ }
131
+
132
+ /**
133
+ * Start the marquee animation
134
+ */
135
+ function startMarquee() {
136
+ if (marqueeInterval) return;
137
+ marqueeInterval = setInterval(() => {
138
+ marqueeFrame = (marqueeFrame + 1) % (MARQUEE_CHARS.length * MARQUEE_COLORS.length);
139
+ // Only trigger re-render if there's a callback and we're enabled
140
+ if (casinoEnabled && marqueeCallback) {
141
+ marqueeCallback();
142
+ }
143
+ }, 150);
144
+ }
145
+
146
+ /**
147
+ * Stop the marquee animation
148
+ */
149
+ function stopMarquee() {
150
+ if (marqueeInterval) {
151
+ clearInterval(marqueeInterval);
152
+ marqueeInterval = null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Get the current marquee border characters for a position
158
+ * @param {number} position - Position along the border (0-based)
159
+ * @param {number} total - Total border length
160
+ * @returns {string} Colored character
161
+ */
162
+ function getMarqueeChar(position, total) {
163
+ const offset = (marqueeFrame + position) % MARQUEE_COLORS.length;
164
+ const charOffset = Math.floor((marqueeFrame + position) / 2) % MARQUEE_CHARS.length;
165
+ return MARQUEE_COLORS[offset] + MARQUEE_CHARS[charOffset] + ansi.reset;
166
+ }
167
+
168
+ /**
169
+ * Get a single marquee character for a specific position on the border
170
+ * @param {number} row - Current row (for side borders)
171
+ * @param {number} height - Terminal height
172
+ * @param {'left' | 'right'} side - Which side
173
+ * @returns {string}
174
+ */
175
+ function getMarqueeSideChar(row, height, side) {
176
+ if (!casinoEnabled) return ' ';
177
+
178
+ // For side borders, offset based on row position
179
+ const position = side === 'left' ? row : (height - row);
180
+ const offset = (marqueeFrame + position) % MARQUEE_COLORS.length;
181
+ const charOffset = Math.floor((marqueeFrame + position) / 2) % MARQUEE_CHARS.length;
182
+ return MARQUEE_COLORS[offset] + MARQUEE_CHARS[charOffset] + ansi.reset;
183
+ }
184
+
185
+ /**
186
+ * Get casino mode header badge
187
+ * @returns {string}
188
+ */
189
+ function getHeaderBadge() {
190
+ if (!casinoEnabled) return '';
191
+
192
+ // Flashing "MAX ADDICTION" badge
193
+ const flash = Math.floor(marqueeFrame / 2) % 2 === 0;
194
+ const colors = flash
195
+ ? ansi.bgBrightMagenta + ansi.brightYellow + ansi.bold
196
+ : ansi.bgBrightYellow + ansi.brightMagenta + ansi.bold;
197
+
198
+ return ` ${colors} 🎰 MAX ADDICTION 🎰 ${ansi.reset}`;
199
+ }
200
+
201
+ /**
202
+ * Render a marquee border line
203
+ * @param {number} width - Terminal width
204
+ * @param {'top' | 'bottom'} position - Border position
205
+ * @returns {string}
206
+ */
207
+ function renderMarqueeLine(width, position) {
208
+ if (!casinoEnabled) return '';
209
+
210
+ let line = '';
211
+
212
+ for (let i = 0; i < width; i++) {
213
+ // Top goes right (reverse direction), bottom goes left
214
+ const pos = position === 'top' ? (width - 1 - i) : i;
215
+ line += getMarqueeChar(pos, width * 2);
216
+ }
217
+
218
+ return line;
219
+ }
220
+
221
+ // ============================================================================
222
+ // Slot Reel Animation
223
+ // ============================================================================
224
+
225
+ /**
226
+ * Start slot reel spinning animation
227
+ * @param {Function} renderCallback - Called on each frame
228
+ */
229
+ function startSlotReels(renderCallback) {
230
+ if (slotReelInterval || !casinoEnabled) return;
231
+
232
+ isSpinning = true;
233
+ slotReelFrame = 0;
234
+
235
+ slotReelInterval = setInterval(() => {
236
+ slotReelFrame++;
237
+ if (renderCallback) renderCallback();
238
+ }, 100); // 25% slower than original 80ms
239
+ }
240
+
241
+ /**
242
+ * Stop slot reel animation and show result
243
+ * @param {boolean} hadUpdates - Whether this poll found updates (win)
244
+ * @param {Function} renderCallback - Called to re-render display
245
+ * @param {Object|null} winLevel - The win level object from getWinLevel()
246
+ */
247
+ function stopSlotReels(hadUpdates = false, renderCallback = null, winLevel = null) {
248
+ if (slotReelInterval) {
249
+ clearInterval(slotReelInterval);
250
+ slotReelInterval = null;
251
+ }
252
+ isSpinning = false;
253
+
254
+ // Clear any existing result display
255
+ if (slotResultInterval) {
256
+ clearInterval(slotResultInterval);
257
+ slotResultInterval = null;
258
+ }
259
+
260
+ slotResultIsWin = hadUpdates;
261
+ slotResultFlashFrame = 0;
262
+ slotResultRenderCallback = renderCallback;
263
+
264
+ // Set result label based on win level
265
+ if (!hadUpdates) {
266
+ slotResultLabel = { text: 'NOTHING', color: ansi.gray, emoji: '😴' };
267
+ } else if (!winLevel || winLevel.key === 'small') {
268
+ slotResultLabel = { text: 'WIN', color: ansi.green, emoji: '✨' };
269
+ } else if (winLevel.key === 'medium') {
270
+ slotResultLabel = { text: 'NICE WIN', color: ansi.yellow, emoji: '🎉' };
271
+ } else if (winLevel.key === 'large') {
272
+ slotResultLabel = { text: 'BIG WIN', color: ansi.brightYellow, emoji: '🔥' };
273
+ } else if (winLevel.key === 'huge') {
274
+ slotResultLabel = { text: 'HUGE WIN', color: ansi.brightMagenta, emoji: '💥' };
275
+ } else if (winLevel.key === 'jackpot') {
276
+ slotResultLabel = { text: '💰 JACKPOT 💰', color: ansi.brightCyan, emoji: '7️⃣', isJackpot: true };
277
+ } else if (winLevel.key === 'mega') {
278
+ slotResultLabel = { text: '🎰💰 MEGA JACKPOT 💰🎰', color: ansi.brightRed, emoji: '7️⃣', isJackpot: true };
279
+ } else {
280
+ slotResultLabel = { text: 'WIN', color: ansi.green, emoji: '✨' };
281
+ }
282
+
283
+ if (hadUpdates) {
284
+ // WIN: Pick symbol based on win level
285
+ let winSymbol;
286
+ if (slotResultLabel.isJackpot) {
287
+ winSymbol = '7️⃣'; // Classic jackpot sevens
288
+ } else {
289
+ winSymbol = SLOT_SYMBOLS[Math.floor(Math.random() * SLOT_SYMBOLS.length)];
290
+ }
291
+ slotResult = [winSymbol, winSymbol, winSymbol, winSymbol, winSymbol];
292
+
293
+ // Flash animation for wins (longer for jackpots)
294
+ const flashDuration = slotResultLabel.isJackpot ? 40 : 20;
295
+ slotResultInterval = setInterval(() => {
296
+ slotResultFlashFrame++;
297
+ if (slotResultFlashFrame > flashDuration) {
298
+ clearInterval(slotResultInterval);
299
+ slotResultInterval = null;
300
+ slotResult = null;
301
+ slotResultLabel = null;
302
+ if (slotResultRenderCallback) slotResultRenderCallback();
303
+ } else if (slotResultRenderCallback) {
304
+ slotResultRenderCallback();
305
+ }
306
+ }, 150);
307
+ } else {
308
+ // NO WIN: Show random final symbols
309
+ slotResult = [];
310
+ for (let i = 0; i < 5; i++) {
311
+ const idx = (slotReelFrame + i * 3) % SLOT_SYMBOLS.length;
312
+ slotResult.push(SLOT_SYMBOLS[idx]);
313
+ }
314
+
315
+ // Display for 2 seconds then fade
316
+ setTimeout(() => {
317
+ slotResult = null;
318
+ slotResultLabel = null;
319
+ if (slotResultRenderCallback) slotResultRenderCallback();
320
+ }, 2000);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Get the current slot result label
326
+ * @returns {Object|null}
327
+ */
328
+ function getSlotResultLabel() {
329
+ return slotResultLabel;
330
+ }
331
+
332
+ /**
333
+ * Check if slot reels are spinning
334
+ * @returns {boolean}
335
+ */
336
+ function isSlotSpinning() {
337
+ return isSpinning;
338
+ }
339
+
340
+ /**
341
+ * Check if there's a slot result being displayed
342
+ * @returns {boolean}
343
+ */
344
+ function hasSlotResult() {
345
+ return slotResult !== null;
346
+ }
347
+
348
+ /**
349
+ * Check if slots are active (spinning or showing result)
350
+ * @returns {boolean}
351
+ */
352
+ function isSlotsActive() {
353
+ return isSpinning || slotResult !== null;
354
+ }
355
+
356
+ /**
357
+ * Get current slot reel display (5 reels) with emojis on white backgrounds
358
+ * @returns {string}
359
+ */
360
+ function getSlotReelDisplay() {
361
+ // Show result if we have one
362
+ if (slotResult) {
363
+ const reels = [];
364
+ for (let i = 0; i < 5; i++) {
365
+ if (slotResultIsWin) {
366
+ // Flashing effect for wins - alternate between bright and dim
367
+ const flash = slotResultFlashFrame % 2 === 0;
368
+ const bg = flash ? ansi.bgBrightYellow : ansi.bgBrightWhite;
369
+ reels.push(`${bg} ${slotResult[i]} ${ansi.reset}`);
370
+ } else {
371
+ // Static pure white background for no-win results
372
+ reels.push(`${ansi.bgBrightWhite} ${slotResult[i]} ${ansi.reset}`);
373
+ }
374
+ }
375
+ return reels.join(`${ansi.bgBlack} ${ansi.reset}`);
376
+ }
377
+
378
+ // Show spinning reels
379
+ if (!isSpinning) return '';
380
+
381
+ const reels = [];
382
+ for (let i = 0; i < 5; i++) {
383
+ const idx = (slotReelFrame + i * 3) % SLOT_SYMBOLS.length;
384
+ // Each emoji on pure white background
385
+ reels.push(`${ansi.bgBrightWhite} ${SLOT_SYMBOLS[idx]} ${ansi.reset}`);
386
+ }
387
+
388
+ // Join with black background space between
389
+ return reels.join(`${ansi.bgBlack} ${ansi.reset}`);
390
+ }
391
+
392
+ // ============================================================================
393
+ // Win Animations
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Get win level based on total lines changed
398
+ * @param {number} linesChanged - Total lines (added + deleted)
399
+ * @returns {Object|null}
400
+ */
401
+ function getWinLevel(linesChanged) {
402
+ for (const [key, level] of Object.entries(WIN_LEVELS)) {
403
+ if (linesChanged >= level.min && linesChanged <= level.max) {
404
+ return { key, ...level };
405
+ }
406
+ }
407
+ return null;
408
+ }
409
+
410
+ /**
411
+ * Trigger a win animation
412
+ * @param {number} linesAdded
413
+ * @param {number} linesDeleted
414
+ * @param {Function} renderCallback
415
+ */
416
+ function triggerWin(linesAdded, linesDeleted, renderCallback) {
417
+ if (!casinoEnabled) return;
418
+
419
+ const totalLines = linesAdded + linesDeleted;
420
+ currentWinLevel = getWinLevel(totalLines);
421
+
422
+ if (!currentWinLevel) return;
423
+
424
+ // Update stats
425
+ casinoStats.totalLinesAdded += linesAdded;
426
+ casinoStats.totalLinesDeleted += linesDeleted;
427
+ casinoStats.pollsWithUpdates++;
428
+
429
+ if (totalLines >= 500) casinoStats.bigWins++;
430
+ if (totalLines >= 1000) casinoStats.jackpots++;
431
+ if (totalLines >= 5000) casinoStats.megaJackpots++;
432
+
433
+ // Start animation
434
+ winAnimationFrame = 0;
435
+ stopWinAnimation();
436
+
437
+ winAnimationInterval = setInterval(() => {
438
+ winAnimationFrame++;
439
+ if (winAnimationFrame > 20) {
440
+ stopWinAnimation();
441
+ currentWinLevel = null;
442
+ }
443
+ if (renderCallback) renderCallback();
444
+ }, 100);
445
+ }
446
+
447
+ /**
448
+ * Stop win animation
449
+ */
450
+ function stopWinAnimation() {
451
+ if (winAnimationInterval) {
452
+ clearInterval(winAnimationInterval);
453
+ winAnimationInterval = null;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Get current win animation display
459
+ * @param {number} width - Available width
460
+ * @returns {string}
461
+ */
462
+ function getWinDisplay(width) {
463
+ if (!currentWinLevel || !casinoEnabled) return '';
464
+
465
+ const flashOn = winAnimationFrame % 2 === 0;
466
+ const label = currentWinLevel.label;
467
+ const color = flashOn ? currentWinLevel.color : ansi.dim + currentWinLevel.color;
468
+
469
+ const padding = Math.max(0, Math.floor((width - label.length) / 2));
470
+
471
+ return color + ' '.repeat(padding) + label + ' '.repeat(padding) + ansi.reset;
472
+ }
473
+
474
+ /**
475
+ * Check if win animation is active
476
+ * @returns {boolean}
477
+ */
478
+ function isWinAnimating() {
479
+ return currentWinLevel !== null;
480
+ }
481
+
482
+ // ============================================================================
483
+ // Loss/Failure Effects
484
+ // ============================================================================
485
+
486
+ let lossAnimationFrame = 0;
487
+ let lossAnimationInterval = null;
488
+ let lossMessage = null;
489
+
490
+ /**
491
+ * Trigger a loss animation (merge conflict, switch failure)
492
+ * @param {string} message - Loss message
493
+ * @param {Function} renderCallback
494
+ */
495
+ function triggerLoss(message, renderCallback) {
496
+ if (!casinoEnabled) return;
497
+
498
+ lossMessage = message;
499
+ lossAnimationFrame = 0;
500
+ stopLossAnimation();
501
+
502
+ lossAnimationInterval = setInterval(() => {
503
+ lossAnimationFrame++;
504
+ if (lossAnimationFrame > 15) {
505
+ stopLossAnimation();
506
+ lossMessage = null;
507
+ }
508
+ if (renderCallback) renderCallback();
509
+ }, 120);
510
+ }
511
+
512
+ /**
513
+ * Stop loss animation
514
+ */
515
+ function stopLossAnimation() {
516
+ if (lossAnimationInterval) {
517
+ clearInterval(lossAnimationInterval);
518
+ lossAnimationInterval = null;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Get loss animation display
524
+ * @param {number} width
525
+ * @returns {string}
526
+ */
527
+ function getLossDisplay(width) {
528
+ if (!lossMessage || !casinoEnabled) return '';
529
+
530
+ const flashOn = lossAnimationFrame % 2 === 0;
531
+ const symbols = '💀 ';
532
+ const display = `${symbols}${lossMessage}${symbols}`;
533
+ const color = flashOn ? ansi.bgRed + ansi.white : ansi.bgBlack + ansi.red;
534
+
535
+ const padding = Math.max(0, Math.floor((width - display.length) / 2));
536
+
537
+ return color + ' '.repeat(padding) + display + ' '.repeat(padding) + ansi.reset;
538
+ }
539
+
540
+ /**
541
+ * Check if loss animation is active
542
+ * @returns {boolean}
543
+ */
544
+ function isLossAnimating() {
545
+ return lossMessage !== null;
546
+ }
547
+
548
+ // ============================================================================
549
+ // Stats Tracking
550
+ // ============================================================================
551
+
552
+ /**
553
+ * Record a poll (each pull of the lever)
554
+ * @param {boolean} hadUpdates - Whether updates were found
555
+ */
556
+ function recordPoll(hadUpdates) {
557
+ if (!casinoEnabled) return;
558
+
559
+ casinoStats.totalPolls++;
560
+
561
+ if (hadUpdates) {
562
+ casinoStats.consecutivePolls++;
563
+ casinoStats.lastHitTime = Date.now();
564
+ } else {
565
+ casinoStats.nearMisses++;
566
+ // Reset streak on miss
567
+ casinoStats.consecutivePolls = 0;
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Get current session stats
573
+ * @returns {Object}
574
+ */
575
+ function getStats() {
576
+ const elapsed = Date.now() - casinoStats.sessionStart;
577
+ const hours = Math.floor(elapsed / 3600000);
578
+ const minutes = Math.floor((elapsed % 3600000) / 60000);
579
+
580
+ // Calculate hit rate (percentage of polls that had updates)
581
+ const hitRate = casinoStats.totalPolls > 0
582
+ ? Math.round((casinoStats.pollsWithUpdates / casinoStats.totalPolls) * 100)
583
+ : 0;
584
+
585
+ // Time since last hit
586
+ let timeSinceLastHit = 'Never';
587
+ if (casinoStats.lastHitTime) {
588
+ const sinceHit = Date.now() - casinoStats.lastHitTime;
589
+ const hitMins = Math.floor(sinceHit / 60000);
590
+ const hitSecs = Math.floor((sinceHit % 60000) / 1000);
591
+ timeSinceLastHit = hitMins > 0 ? `${hitMins}m ${hitSecs}s` : `${hitSecs}s`;
592
+ }
593
+
594
+ // Luck meter - weighted random that trends with recent activity
595
+ const baseLuck = 50 + Math.random() * 30;
596
+ const streakBonus = Math.min(casinoStats.consecutivePolls * 5, 20);
597
+ const luckMeter = Math.min(Math.round(baseLuck + streakBonus), 99);
598
+
599
+ // House edge - oscillates between 55% and 100%
600
+ const houseEdge = Math.round(55 + Math.random() * 45);
601
+
602
+ // Vibes quality - random emoji that changes slowly (based on seconds)
603
+ const vibesEmojis = ['😎', '🔥', '✨', '💫', '🌟', '⚡', '🎯', '💪', '🚀', '💯'];
604
+ const vibesIndex = Math.floor(Date.now() / 3000) % vibesEmojis.length;
605
+ const vibesQuality = vibesEmojis[vibesIndex];
606
+
607
+ // Dopamine hits - based on updates received, with multiplier for big wins
608
+ const baseHits = casinoStats.pollsWithUpdates;
609
+ const bonusHits = casinoStats.bigWins * 2 + casinoStats.jackpots * 5 + casinoStats.megaJackpots * 10;
610
+ const dopamineHits = baseHits + bonusHits;
611
+
612
+ // Net winnings: total lines gained minus poll cost (1 per poll)
613
+ const totalLines = casinoStats.totalLinesAdded + casinoStats.totalLinesDeleted;
614
+ const netWinnings = totalLines - casinoStats.totalPolls;
615
+
616
+ return {
617
+ ...casinoStats,
618
+ sessionDuration: hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`,
619
+ totalLines,
620
+ hitRate,
621
+ timeSinceLastHit,
622
+ luckMeter,
623
+ houseEdge,
624
+ vibesQuality,
625
+ dopamineHits,
626
+ netWinnings,
627
+ };
628
+ }
629
+
630
+ /**
631
+ * Reset session stats
632
+ */
633
+ function resetStats() {
634
+ casinoStats = {
635
+ totalLinesAdded: 0,
636
+ totalLinesDeleted: 0,
637
+ consecutivePolls: 0,
638
+ pollsWithUpdates: 0,
639
+ bigWins: 0,
640
+ jackpots: 0,
641
+ megaJackpots: 0,
642
+ sessionStart: Date.now(),
643
+ totalPolls: 0,
644
+ nearMisses: 0,
645
+ lastHitTime: null,
646
+ };
647
+ }
648
+
649
+ /**
650
+ * Get stats display for footer
651
+ * @returns {string}
652
+ */
653
+ function getStatsDisplay() {
654
+ if (!casinoEnabled) return '';
655
+
656
+ const stats = getStats();
657
+ const linesDisplay = stats.totalLines > 0
658
+ ? `${ansi.brightGreen}+${stats.totalLinesAdded}${ansi.reset}/${ansi.brightRed}-${stats.totalLinesDeleted}${ansi.reset}`
659
+ : '0';
660
+
661
+ let display = `${ansi.yellow}🎰 Winnings: ${linesDisplay} lines`;
662
+
663
+ if (stats.consecutivePolls > 1) {
664
+ display += ` ${ansi.brightMagenta}(${stats.consecutivePolls}x streak!)${ansi.reset}`;
665
+ }
666
+
667
+ if (stats.jackpots > 0) {
668
+ display += ` ${ansi.brightCyan}💰×${stats.jackpots}${ansi.reset}`;
669
+ }
670
+
671
+ return display + ansi.reset;
672
+ }
673
+
674
+ // ============================================================================
675
+ // Module Exports
676
+ // ============================================================================
677
+
678
+ module.exports = {
679
+ // Mode control
680
+ isEnabled,
681
+ enable,
682
+ disable,
683
+ toggle,
684
+
685
+ // Render callback
686
+ setRenderCallback,
687
+
688
+ // Marquee
689
+ renderMarqueeLine,
690
+ getMarqueeSideChar,
691
+ getHeaderBadge,
692
+ startMarquee,
693
+ stopMarquee,
694
+
695
+ // Slot reels
696
+ startSlotReels,
697
+ stopSlotReels,
698
+ isSlotSpinning,
699
+ hasSlotResult,
700
+ isSlotsActive,
701
+ getSlotReelDisplay,
702
+ getSlotResultLabel,
703
+
704
+ // Win effects
705
+ triggerWin,
706
+ getWinDisplay,
707
+ isWinAnimating,
708
+ getWinLevel,
709
+ WIN_LEVELS,
710
+
711
+ // Loss effects
712
+ triggerLoss,
713
+ getLossDisplay,
714
+ isLossAnimating,
715
+
716
+ // Stats
717
+ recordPoll,
718
+ getStats,
719
+ resetStats,
720
+ getStatsDisplay,
721
+ };