@buley/hexgrid-3d 1.0.0 → 1.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.
package/src/Snapshot.ts CHANGED
@@ -1,31 +1,31 @@
1
1
  /**
2
2
  * Unified Snapshot API
3
- *
3
+ *
4
4
  * Single comprehensive API to get ALL statistics, predictions, and insights
5
5
  * about the current game state in one easy method call.
6
- *
6
+ *
7
7
  * @module Snapshot
8
- *
8
+ *
9
9
  * @example Basic Usage
10
10
  * ```typescript
11
11
  * import { generateSnapshot } from '@buley/hexgrid-3d'
12
- *
12
+ *
13
13
  * const snapshot = generateSnapshot(cells, history, conquests, getNeighbors)
14
- *
14
+ *
15
15
  * // Access player data
16
16
  * console.log(snapshot.players[0].winProbability) // 0.72
17
17
  * console.log(snapshot.players[0].sparklineAscii) // "▁▂▃▅▆█"
18
- *
18
+ *
19
19
  * // Access game state
20
20
  * console.log(snapshot.indices.dominance) // 0.65
21
21
  * console.log(snapshot.predictions.likelyWinner) // 2
22
- *
22
+ *
23
23
  * // Get insights
24
24
  * console.log(snapshot.insights)
25
25
  * // ["🏆 Player 2 dominates with 65.2% of territory",
26
26
  * // "🚀 Leader's territory growing steadily"]
27
27
  * ```
28
- *
28
+ *
29
29
  * @example Full Response Object
30
30
  * ```typescript
31
31
  * // GameSnapshot example response:
@@ -35,7 +35,7 @@
35
35
  * totalCells: 1000,
36
36
  * occupiedCells: 850,
37
37
  * playerCount: 4,
38
- *
38
+ *
39
39
  * players: [{
40
40
  * id: 2,
41
41
  * cellCount: 320,
@@ -64,7 +64,7 @@
64
64
  * leadOverSecond: 45,
65
65
  * turnsUntilOvertake: null
66
66
  * }, ...],
67
- *
67
+ *
68
68
  * inequality: {
69
69
  * gini: 0.42,
70
70
  * theil: 0.35,
@@ -74,7 +74,7 @@
74
74
  * zipfCoefficient: 1.15,
75
75
  * interpretation: "Emerging dominance"
76
76
  * },
77
- *
77
+ *
78
78
  * indices: {
79
79
  * dominance: 0.65,
80
80
  * volatility: 0.35,
@@ -82,7 +82,7 @@
82
82
  * competitiveness: 0.45,
83
83
  * stability: 0.68
84
84
  * },
85
- *
85
+ *
86
86
  * predictions: {
87
87
  * likelyWinner: 2,
88
88
  * winnerConfidence: 0.72,
@@ -91,13 +91,13 @@
91
91
  * secondPlaceChallenger: 3,
92
92
  * comebackPossibility: 0.28
93
93
  * },
94
- *
94
+ *
95
95
  * anomalies: {
96
96
  * outliers: [{ playerId: 4, type: 'growth_explosion', ... }],
97
97
  * hasAnomalies: true,
98
98
  * anomalyCount: 1
99
99
  * },
100
- *
100
+ *
101
101
  * insights: [
102
102
  * "📈 Player 2 leads with majority control (37.6%)",
103
103
  * "🚀 Leader's territory growing steadily (slope: 4.2/turn)",
@@ -134,8 +134,8 @@ import {
134
134
  sparkline,
135
135
  sparklineSvg,
136
136
  computeTerritoryStats,
137
- type TerritoryStats
138
- } from './algorithms/AdvancedStatistics'
137
+ type TerritoryStats,
138
+ } from './algorithms/AdvancedStatistics';
139
139
 
140
140
  import {
141
141
  BetaDistribution,
@@ -159,8 +159,8 @@ import {
159
159
  bayesianConquestRate,
160
160
  bayesianChangepoint,
161
161
  generateProbabilitySnapshot,
162
- type ProbabilitySnapshot
163
- } from './algorithms/BayesianStatistics'
162
+ type ProbabilitySnapshot,
163
+ } from './algorithms/BayesianStatistics';
164
164
 
165
165
  import {
166
166
  detectOutliersZScore,
@@ -178,10 +178,13 @@ import {
178
178
  type OutlierResult,
179
179
  type TimeSeriesAnomaly,
180
180
  type GameAnomaly,
181
- type MultivariateOutlierResult
182
- } from './algorithms/OutlierDetection'
181
+ type MultivariateOutlierResult,
182
+ } from './algorithms/OutlierDetection';
183
183
 
184
- import { findConnectedComponents, analyzeTerritorBoundaries } from './algorithms/GraphAlgorithms'
184
+ import {
185
+ findConnectedComponents,
186
+ analyzeTerritorBoundaries,
187
+ } from './algorithms/GraphAlgorithms';
185
188
 
186
189
  // ═══════════════════════════════════════════════════════════════════════════
187
190
  // UNIFIED SNAPSHOT TYPES
@@ -189,7 +192,7 @@ import { findConnectedComponents, analyzeTerritorBoundaries } from './algorithms
189
192
 
190
193
  /**
191
194
  * Player-specific statistics
192
- *
195
+ *
193
196
  * @example
194
197
  * ```typescript
195
198
  * const player: PlayerSnapshot = {
@@ -224,76 +227,76 @@ import { findConnectedComponents, analyzeTerritorBoundaries } from './algorithms
224
227
  */
225
228
  export interface PlayerSnapshot {
226
229
  /** Unique player identifier */
227
- id: number
228
-
230
+ id: number;
231
+
229
232
  // Territory
230
233
  /** Current number of cells owned */
231
- cellCount: number
234
+ cellCount: number;
232
235
  /** Fraction of total occupied cells (0-1) */
233
- shareOfTotal: number
236
+ shareOfTotal: number;
234
237
  /** Current ranking (1 = leader) */
235
- rank: number
236
-
238
+ rank: number;
239
+
237
240
  // History
238
241
  /** Number of turns of history available */
239
- historyLength: number
242
+ historyLength: number;
240
243
  /** Territory count history (last 20 turns unless includeFullHistory=true) */
241
- history: number[]
244
+ history: number[];
242
245
  /** Recent territory trend direction */
243
- recentTrend: 'growing' | 'shrinking' | 'stable'
246
+ recentTrend: 'growing' | 'shrinking' | 'stable';
244
247
  /** Linear regression slope (cells per turn) */
245
- trendSlope: number
248
+ trendSlope: number;
246
249
  /** R² confidence in trend (0-1) */
247
- trendConfidence: number
248
-
250
+ trendConfidence: number;
251
+
249
252
  // Predictions
250
253
  /** Bayesian probability of winning (0-1) */
251
- winProbability: number
254
+ winProbability: number;
252
255
  /** 95% credible interval for win probability */
253
- winCredibleInterval: [number, number]
256
+ winCredibleInterval: [number, number];
254
257
  /** Forecasted territory for next 10 turns */
255
- forecastNext10: number[]
258
+ forecastNext10: number[];
256
259
  /** Kalman filter smoothed estimate */
257
- kalmanEstimate: number
260
+ kalmanEstimate: number;
258
261
  /** Kalman filter uncertainty (±) */
259
- kalmanUncertainty: number
260
-
262
+ kalmanUncertainty: number;
263
+
261
264
  // Performance
262
265
  /** Bayesian conquest success rate */
263
- conquestRate: number
266
+ conquestRate: number;
264
267
  /** 95% credible interval for conquest rate */
265
- conquestRateCI: [number, number]
268
+ conquestRateCI: [number, number];
266
269
  /** Average cells gained per turn */
267
- avgGrowthPerTurn: number
270
+ avgGrowthPerTurn: number;
268
271
  /** Territory volatility (coefficient of variation) */
269
- volatility: number
270
-
272
+ volatility: number;
273
+
271
274
  // Topology
272
275
  /** Number of disconnected territory regions */
273
- numRegions: number
276
+ numRegions: number;
274
277
  /** Size of largest connected region */
275
- largestRegionSize: number
278
+ largestRegionSize: number;
276
279
  /** Number of cells on territory border */
277
- borderCellCount: number
280
+ borderCellCount: number;
278
281
  /** Territory shape compactness (0-1, higher = more compact) */
279
- compactness: number
280
-
282
+ compactness: number;
283
+
281
284
  // Sparklines
282
285
  /** ASCII sparkline visualization of history */
283
- sparklineAscii: string
286
+ sparklineAscii: string;
284
287
  /** SVG path data for sparkline */
285
- sparklineSvgPath: string
286
-
288
+ sparklineSvgPath: string;
289
+
287
290
  // Relative metrics
288
291
  /** Lead over second place (null if not leader) */
289
- leadOverSecond: number | null
292
+ leadOverSecond: number | null;
290
293
  /** Estimated turns until this player could overtake leader */
291
- turnsUntilOvertake: number | null
294
+ turnsUntilOvertake: number | null;
292
295
  }
293
296
 
294
297
  /**
295
298
  * Inequality metrics for territory distribution
296
- *
299
+ *
297
300
  * @example
298
301
  * ```typescript
299
302
  * const inequality: InequalityMetrics = {
@@ -308,18 +311,18 @@ export interface PlayerSnapshot {
308
311
  * ```
309
312
  */
310
313
  export interface InequalityMetrics {
311
- gini: number
312
- theil: number
313
- atkinson: number
314
- herfindahl: number
315
- paretoRatio: number
316
- zipfCoefficient: number
317
- interpretation: string
314
+ gini: number;
315
+ theil: number;
316
+ atkinson: number;
317
+ herfindahl: number;
318
+ paretoRatio: number;
319
+ zipfCoefficient: number;
320
+ interpretation: string;
318
321
  }
319
322
 
320
323
  /**
321
324
  * Diversity/entropy metrics for territory distribution
322
- *
325
+ *
323
326
  * @example
324
327
  * ```typescript
325
328
  * const diversity: DiversityMetrics = {
@@ -332,16 +335,16 @@ export interface InequalityMetrics {
332
335
  * ```
333
336
  */
334
337
  export interface DiversityMetrics {
335
- shannon: number
336
- normalized: number
337
- renyi: number
338
- tsallis: number
339
- interpretation: string
338
+ shannon: number;
339
+ normalized: number;
340
+ renyi: number;
341
+ tsallis: number;
342
+ interpretation: string;
340
343
  }
341
344
 
342
345
  /**
343
346
  * Distribution statistics for territory counts
344
- *
347
+ *
345
348
  * @example
346
349
  * ```typescript
347
350
  * const distribution: DistributionMetrics = {
@@ -359,21 +362,21 @@ export interface DiversityMetrics {
359
362
  * ```
360
363
  */
361
364
  export interface DistributionMetrics {
362
- mean: number
363
- median: number
364
- stdDev: number
365
- skewness: number
366
- kurtosis: number
367
- coefficientOfVariation: number
368
- iqr: number
369
- min: number
370
- max: number
371
- range: number
365
+ mean: number;
366
+ median: number;
367
+ stdDev: number;
368
+ skewness: number;
369
+ kurtosis: number;
370
+ coefficientOfVariation: number;
371
+ iqr: number;
372
+ min: number;
373
+ max: number;
374
+ range: number;
372
375
  }
373
376
 
374
377
  /**
375
378
  * Game state indices (0-1 scale)
376
- *
379
+ *
377
380
  * @example
378
381
  * ```typescript
379
382
  * const indices: GameIndices = {
@@ -387,20 +390,20 @@ export interface DistributionMetrics {
387
390
  */
388
391
  export interface GameIndices {
389
392
  /** How dominated by a single player (0=even, 1=total domination) */
390
- dominance: number
393
+ dominance: number;
391
394
  /** How much territory changes each turn (0=static, 1=chaotic) */
392
- volatility: number
395
+ volatility: number;
393
396
  /** How predictable outcomes are (0=random, 1=deterministic) */
394
- predictability: number
397
+ predictability: number;
395
398
  /** How close the competition (0=decided, 1=neck-and-neck) */
396
- competitiveness: number
399
+ competitiveness: number;
397
400
  /** How stable territory boundaries are (0=fluid, 1=locked) */
398
- stability: number
401
+ stability: number;
399
402
  }
400
403
 
401
404
  /**
402
405
  * Game outcome predictions
403
- *
406
+ *
404
407
  * @example
405
408
  * ```typescript
406
409
  * const predictions: GamePredictions = {
@@ -415,22 +418,22 @@ export interface GameIndices {
415
418
  */
416
419
  export interface GamePredictions {
417
420
  /** Player ID most likely to win */
418
- likelyWinner: number | null
421
+ likelyWinner: number | null;
419
422
  /** Confidence in winner prediction (0-1) */
420
- winnerConfidence: number
423
+ winnerConfidence: number;
421
424
  /** Estimated turns until victory condition */
422
- estimatedTurnsToVictory: number | null
425
+ estimatedTurnsToVictory: number | null;
423
426
  /** Whether game is in endgame phase */
424
- isEndgame: boolean
427
+ isEndgame: boolean;
425
428
  /** Second place player who could challenge */
426
- secondPlaceChallenger: number | null
429
+ secondPlaceChallenger: number | null;
427
430
  /** Probability of comeback by non-leader */
428
- comebackPossibility: number
431
+ comebackPossibility: number;
429
432
  }
430
433
 
431
434
  /**
432
435
  * Territory topology metrics
433
- *
436
+ *
434
437
  * @example
435
438
  * ```typescript
436
439
  * const topology: TopologyMetrics = {
@@ -444,20 +447,20 @@ export interface GamePredictions {
444
447
  */
445
448
  export interface TopologyMetrics {
446
449
  /** Total disconnected regions across all players */
447
- totalRegions: number
450
+ totalRegions: number;
448
451
  /** Average cells per region */
449
- averageRegionSize: number
452
+ averageRegionSize: number;
450
453
  /** How fragmented territories are (0=solid, 1=scattered) */
451
- territoryFragmentation: number
454
+ territoryFragmentation: number;
452
455
  /** Percentage of cells that are border cells */
453
- borderCellPercentage: number
456
+ borderCellPercentage: number;
454
457
  /** Average compactness across all territories */
455
- avgCompactness: number
458
+ avgCompactness: number;
456
459
  }
457
460
 
458
461
  /**
459
462
  * Time series analysis metrics
460
- *
463
+ *
461
464
  * @example
462
465
  * ```typescript
463
466
  * const timeSeries: TimeSeriesMetrics = {
@@ -471,20 +474,20 @@ export interface TopologyMetrics {
471
474
  */
472
475
  export interface TimeSeriesMetrics {
473
476
  /** Overall trend type */
474
- overallTrend: 'convergent' | 'divergent' | 'cyclical' | 'chaotic'
477
+ overallTrend: 'convergent' | 'divergent' | 'cyclical' | 'chaotic';
475
478
  /** Turn numbers where significant changes occurred */
476
- changePoints: number[]
479
+ changePoints: number[];
477
480
  /** Strength of overall trend (0-1) */
478
- trendStrength: number
481
+ trendStrength: number;
479
482
  /** Autocorrelation coefficient */
480
- autocorrelation: number
483
+ autocorrelation: number;
481
484
  /** Whether periodic patterns detected */
482
- seasonality: boolean
485
+ seasonality: boolean;
483
486
  }
484
487
 
485
488
  /**
486
489
  * Comparison metrics with past states
487
- *
490
+ *
488
491
  * @example
489
492
  * ```typescript
490
493
  * const comparisons: ComparisonMetrics = {
@@ -497,18 +500,18 @@ export interface TimeSeriesMetrics {
497
500
  */
498
501
  export interface ComparisonMetrics {
499
502
  /** Territory change per player vs 5 turns ago */
500
- vs5TurnsAgo: { [playerId: number]: number }
503
+ vs5TurnsAgo: { [playerId: number]: number };
501
504
  /** Territory change per player vs 10 turns ago */
502
- vs10TurnsAgo: { [playerId: number]: number }
505
+ vs10TurnsAgo: { [playerId: number]: number };
503
506
  /** KL divergence from uniform distribution */
504
- divergenceFromUniform: number
507
+ divergenceFromUniform: number;
505
508
  /** JS divergence from previous turn */
506
- divergenceFromPrevious: number
509
+ divergenceFromPrevious: number;
507
510
  }
508
511
 
509
512
  /**
510
513
  * Anomaly detection summary
511
- *
514
+ *
512
515
  * @example
513
516
  * ```typescript
514
517
  * const anomalies: AnomalySummary = {
@@ -528,18 +531,18 @@ export interface ComparisonMetrics {
528
531
  */
529
532
  export interface AnomalySummary {
530
533
  /** Detected game anomalies */
531
- outliers: GameAnomaly[]
534
+ outliers: GameAnomaly[];
532
535
  /** Whether any anomalies were detected */
533
- hasAnomalies: boolean
536
+ hasAnomalies: boolean;
534
537
  /** Total count of anomalies */
535
- anomalyCount: number
538
+ anomalyCount: number;
536
539
  /** Type of most severe anomaly (if any) */
537
- mostSevere: string | null
540
+ mostSevere: string | null;
538
541
  }
539
542
 
540
543
  /**
541
544
  * Complete game snapshot with all statistics
542
- *
545
+ *
543
546
  * @example Full Response Structure
544
547
  * ```typescript
545
548
  * const snapshot: GameSnapshot = {
@@ -548,16 +551,16 @@ export interface AnomalySummary {
548
551
  * totalCells: 1000,
549
552
  * occupiedCells: 850,
550
553
  * playerCount: 4,
551
- *
554
+ *
552
555
  * players: [
553
556
  * { id: 2, cellCount: 320, shareOfTotal: 0.376, rank: 1, ... },
554
557
  * { id: 1, cellCount: 210, shareOfTotal: 0.247, rank: 2, ... },
555
558
  * { id: 3, cellCount: 180, shareOfTotal: 0.212, rank: 3, ... },
556
559
  * { id: 4, cellCount: 140, shareOfTotal: 0.165, rank: 4, ... }
557
560
  * ],
558
- *
561
+ *
559
562
  * territoryStats: { ... },
560
- *
563
+ *
561
564
  * inequality: {
562
565
  * gini: 0.42,
563
566
  * theil: 0.35,
@@ -567,7 +570,7 @@ export interface AnomalySummary {
567
570
  * zipfCoefficient: 1.15,
568
571
  * interpretation: "Emerging dominance"
569
572
  * },
570
- *
573
+ *
571
574
  * diversity: {
572
575
  * shannon: 1.85,
573
576
  * normalized: 0.92,
@@ -575,14 +578,14 @@ export interface AnomalySummary {
575
578
  * tsallis: 1.52,
576
579
  * interpretation: "High diversity"
577
580
  * },
578
- *
581
+ *
579
582
  * distribution: {
580
583
  * mean: 212.5, median: 195, stdDev: 68.2,
581
584
  * skewness: 0.65, kurtosis: 2.8,
582
585
  * coefficientOfVariation: 0.32,
583
586
  * iqr: 95, min: 140, max: 320, range: 180
584
587
  * },
585
- *
588
+ *
586
589
  * indices: {
587
590
  * dominance: 0.65,
588
591
  * volatility: 0.35,
@@ -590,7 +593,7 @@ export interface AnomalySummary {
590
593
  * competitiveness: 0.45,
591
594
  * stability: 0.68
592
595
  * },
593
- *
596
+ *
594
597
  * predictions: {
595
598
  * likelyWinner: 2,
596
599
  * winnerConfidence: 0.72,
@@ -599,19 +602,19 @@ export interface AnomalySummary {
599
602
  * secondPlaceChallenger: 1,
600
603
  * comebackPossibility: 0.28
601
604
  * },
602
- *
605
+ *
603
606
  * anomalies: {
604
607
  * outliers: [],
605
608
  * hasAnomalies: false,
606
609
  * anomalyCount: 0,
607
610
  * mostSevere: null
608
611
  * },
609
- *
612
+ *
610
613
  * probability: { winProbabilities: Map, ... },
611
614
  * topology: { totalRegions: 8, ... },
612
615
  * timeSeries: { overallTrend: 'divergent', ... },
613
616
  * comparisons: { vs5TurnsAgo: {...}, ... },
614
- *
617
+ *
615
618
  * insights: [
616
619
  * "📈 Player 2 leads with 37.6% of territory",
617
620
  * "🚀 Leader's territory growing steadily",
@@ -623,67 +626,67 @@ export interface AnomalySummary {
623
626
  export interface GameSnapshot {
624
627
  // Basic
625
628
  /** Unix timestamp when snapshot was generated */
626
- timestamp: number
629
+ timestamp: number;
627
630
  /** Current turn number */
628
- turnNumber: number
631
+ turnNumber: number;
629
632
  /** Total cells on the grid */
630
- totalCells: number
633
+ totalCells: number;
631
634
  /** Number of cells with an owner */
632
- occupiedCells: number
633
-
635
+ occupiedCells: number;
636
+
634
637
  // Players
635
638
  /** Number of active players */
636
- playerCount: number
639
+ playerCount: number;
637
640
  /** Detailed stats for each player, sorted by rank */
638
- players: PlayerSnapshot[]
639
-
641
+ players: PlayerSnapshot[];
642
+
640
643
  // Overall territory stats
641
644
  /** Aggregate territory statistics */
642
- territoryStats: TerritoryStats
643
-
645
+ territoryStats: TerritoryStats;
646
+
644
647
  // Inequality metrics (all players)
645
648
  /** Inequality measures for territory distribution */
646
- inequality: InequalityMetrics
647
-
649
+ inequality: InequalityMetrics;
650
+
648
651
  // Entropy/diversity
649
652
  /** Diversity/entropy measures */
650
- diversity: DiversityMetrics
651
-
653
+ diversity: DiversityMetrics;
654
+
652
655
  // Distribution metrics
653
656
  /** Statistical distribution of territory counts */
654
- distribution: DistributionMetrics
655
-
657
+ distribution: DistributionMetrics;
658
+
656
659
  // Game state indices
657
660
  /** Normalized game state indicators */
658
- indices: GameIndices
659
-
661
+ indices: GameIndices;
662
+
660
663
  // Predictions
661
664
  /** Game outcome predictions */
662
- predictions: GamePredictions
663
-
665
+ predictions: GamePredictions;
666
+
664
667
  // Anomaly detection
665
668
  /** Detected anomalies and outliers */
666
- anomalies: AnomalySummary
667
-
669
+ anomalies: AnomalySummary;
670
+
668
671
  // Probability snapshot (Bayesian)
669
672
  /** Full Bayesian probability analysis */
670
- probability: ProbabilitySnapshot
671
-
673
+ probability: ProbabilitySnapshot;
674
+
672
675
  // Topology
673
676
  /** Territory topology metrics */
674
- topology: TopologyMetrics
675
-
677
+ topology: TopologyMetrics;
678
+
676
679
  // Time series insights
677
680
  /** Time series analysis */
678
- timeSeries: TimeSeriesMetrics
679
-
681
+ timeSeries: TimeSeriesMetrics;
682
+
680
683
  // Comparisons
681
684
  /** Comparisons with past states */
682
- comparisons: ComparisonMetrics
683
-
685
+ comparisons: ComparisonMetrics;
686
+
684
687
  // Recommendations / insights
685
688
  /** Human-readable insights and observations */
686
- insights: string[]
689
+ insights: string[];
687
690
  }
688
691
 
689
692
  // Re-export all types for easy importing
@@ -693,8 +696,8 @@ export type {
693
696
  OutlierResult,
694
697
  TimeSeriesAnomaly,
695
698
  GameAnomaly,
696
- MultivariateOutlierResult
697
- }
699
+ MultivariateOutlierResult,
700
+ };
698
701
 
699
702
  // ═══════════════════════════════════════════════════════════════════════════
700
703
  // SNAPSHOT GENERATOR
@@ -702,15 +705,15 @@ export type {
702
705
 
703
706
  export interface SnapshotConfig {
704
707
  /** Number of turns for forecasting */
705
- forecastHorizon?: number
708
+ forecastHorizon?: number;
706
709
  /** Number of Monte Carlo samples */
707
- monteCarloSamples?: number
710
+ monteCarloSamples?: number;
708
711
  /** Include detailed history */
709
- includeFullHistory?: boolean
712
+ includeFullHistory?: boolean;
710
713
  /** Calculate topology (can be expensive) */
711
- calculateTopology?: boolean
714
+ calculateTopology?: boolean;
712
715
  /** Generate insights */
713
- generateInsights?: boolean
716
+ generateInsights?: boolean;
714
717
  }
715
718
 
716
719
  /**
@@ -728,174 +731,220 @@ export function generateSnapshot(
728
731
  monteCarloSamples = 1000,
729
732
  includeFullHistory = false,
730
733
  calculateTopology = true,
731
- generateInsights = true
732
- } = config
733
-
734
- const timestamp = Date.now()
735
- const totalCells = cells.length
736
-
734
+ generateInsights = true,
735
+ } = config;
736
+
737
+ const timestamp = Date.now();
738
+ const totalCells = cells.length;
739
+
737
740
  // Count territories
738
- const territoryCounts = new Map<number, number>()
741
+ const territoryCounts = new Map<number, number>();
739
742
  for (const cell of cells) {
740
743
  if (cell.owner !== 0) {
741
- territoryCounts.set(cell.owner, (territoryCounts.get(cell.owner) ?? 0) + 1)
744
+ territoryCounts.set(
745
+ cell.owner,
746
+ (territoryCounts.get(cell.owner) ?? 0) + 1
747
+ );
742
748
  }
743
749
  }
744
-
745
- const occupiedCells = Array.from(territoryCounts.values()).reduce((a, b) => a + b, 0)
746
- const players = Array.from(territoryCounts.keys()).sort((a, b) => a - b)
747
- const playerCount = players.length
748
-
750
+
751
+ const occupiedCells = Array.from(territoryCounts.values()).reduce(
752
+ (a, b) => a + b,
753
+ 0
754
+ );
755
+ const players = Array.from(territoryCounts.keys()).sort((a, b) => a - b);
756
+ const playerCount = players.length;
757
+
749
758
  // Sort by territory size for ranking
750
- const sortedBySize = [...players].sort((a, b) =>
751
- (territoryCounts.get(b) ?? 0) - (territoryCounts.get(a) ?? 0)
752
- )
753
-
759
+ const sortedBySize = [...players].sort(
760
+ (a, b) => (territoryCounts.get(b) ?? 0) - (territoryCounts.get(a) ?? 0)
761
+ );
762
+
754
763
  // Compute turn number from history length
755
- const turnNumber = Math.max(...Array.from(territoryHistory.values()).map(h => h.length), 0)
756
-
757
- // Territory stats
758
- const territoryStats = computeTerritoryStats(territoryCounts, territoryHistory)
759
-
760
- // Get probability snapshot
761
- const probability = generateProbabilitySnapshot(territoryHistory, conquestCounts, {
762
- forecastSteps: forecastHorizon,
763
- samples: monteCarloSamples
764
- })
765
-
764
+ const turnNumber = Math.max(
765
+ ...Array.from(territoryHistory.values()).map((h) => h.length),
766
+ 0
767
+ );
768
+
769
+ // Territory stats - computeTerritoryStats expects Array<{ area: number; perimeter: number }>
770
+ // For now, create a simplified version
771
+ const territoryAreas = Array.from(territoryCounts.entries()).map(
772
+ ([_, count]) => ({
773
+ area: count,
774
+ perimeter: Math.sqrt(count) * 4, // Simplified perimeter calculation
775
+ })
776
+ );
777
+ const territoryStats = computeTerritoryStats(territoryAreas);
778
+
779
+ // Get probability snapshot - generateProbabilitySnapshot only takes labels: string[]
780
+ const playerLabels = Array.from(territoryCounts.keys()).map(String);
781
+ const probability = generateProbabilitySnapshot(playerLabels);
782
+
766
783
  // Compute player snapshots
767
- const playerSnapshots: PlayerSnapshot[] = []
768
-
784
+ const playerSnapshots: PlayerSnapshot[] = [];
785
+
769
786
  for (let rankIdx = 0; rankIdx < sortedBySize.length; rankIdx++) {
770
- const playerId = sortedBySize[rankIdx]
771
- const cellCount = territoryCounts.get(playerId) ?? 0
772
- const history = territoryHistory.get(playerId) ?? []
773
-
787
+ const playerId = sortedBySize[rankIdx];
788
+ const cellCount = territoryCounts.get(playerId) ?? 0;
789
+ const history = territoryHistory.get(playerId) ?? [];
790
+
774
791
  // Trend detection
775
- const trend = history.length > 3 ? detectTrend(history) : null
776
-
777
- // Forecast
778
- const { forecast } = history.length > 2
779
- ? doubleExponentialSmoothing(history, 0.3, 0.1)
780
- : { forecast: () => [] }
781
- const forecastNext10 = forecast(forecastHorizon)
782
-
792
+ const trend = history.length > 3 ? detectTrend(history) : null;
793
+
794
+ // Forecast - doubleExponentialSmoothing returns number[], not an object with forecast method
795
+ const forecastValues =
796
+ history.length > 2 ? doubleExponentialSmoothing(history, 0.3, 0.1) : [];
797
+ const forecastNext10 = forecastValues.slice(0, forecastHorizon);
798
+
783
799
  // Kalman filter
784
- let kalmanEstimate = cellCount
785
- let kalmanUncertainty = 0
800
+ let kalmanEstimate = cellCount;
801
+ let kalmanUncertainty = 0;
786
802
  if (history.length > 3) {
787
- const variance = history.slice(1).reduce((sum, v, i) =>
788
- sum + (v - history[i]) ** 2, 0) / history.length
789
-
803
+ const variance =
804
+ history.slice(1).reduce((sum, v, i) => sum + (v - history[i]) ** 2, 0) /
805
+ history.length;
806
+
790
807
  const filter = new KalmanFilter(
791
808
  history[0],
792
809
  variance || 1,
793
810
  (variance || 1) * 0.1,
794
811
  (variance || 1) * 0.5
795
- )
796
-
812
+ );
813
+
797
814
  for (const measurement of history) {
798
- filter.step(measurement)
815
+ filter.update(measurement);
799
816
  }
800
-
801
- kalmanEstimate = filter.getState()
802
- kalmanUncertainty = filter.getUncertainty()
817
+
818
+ kalmanEstimate = filter.getState();
819
+ kalmanUncertainty = filter.getUncertainty();
803
820
  }
804
-
805
- // Win probability
806
- const winProb = probability.winProbabilities.get(playerId) ?? 0
807
- const winCI = probability.winCredibleIntervals.get(playerId) ?? [0, 0]
808
-
809
- // Conquest rate
810
- const conquests = conquestCounts.get(playerId) ?? { successes: 0, opportunities: 0 }
811
- const conquestResult = bayesianConquestRate(conquests.successes, conquests.opportunities)
812
-
821
+
822
+ // Win probability - ProbabilitySnapshot only has probabilities array
823
+ const playerProb = probability.probabilities.find(
824
+ (p) => p.label === String(playerId)
825
+ );
826
+ const winProb = playerProb?.probability ?? 0;
827
+ const winCI: [number, number] = [
828
+ Math.max(0, winProb - 0.1),
829
+ Math.min(1, winProb + 0.1),
830
+ ]; // Simplified CI
831
+
832
+ // Conquest rate - bayesianConquestRate returns a number, not an object
833
+ const conquests = conquestCounts.get(playerId) ?? {
834
+ successes: 0,
835
+ opportunities: 0,
836
+ };
837
+ const conquestRate = bayesianConquestRate(
838
+ conquests.successes,
839
+ conquests.opportunities
840
+ );
841
+ const conquestRateCI: [number, number] = [
842
+ Math.max(0, conquestRate - 0.1),
843
+ Math.min(1, conquestRate + 0.1),
844
+ ]; // Simplified CI
845
+
813
846
  // Volatility
814
- const volatility = history.length > 1
815
- ? Math.sqrt(history.slice(1).reduce((sum, v, i) =>
816
- sum + (v - history[i]) ** 2, 0) / (history.length - 1)) / (cellCount || 1)
817
- : 0
818
-
847
+ const volatility =
848
+ history.length > 1
849
+ ? Math.sqrt(
850
+ history
851
+ .slice(1)
852
+ .reduce((sum, v, i) => sum + (v - history[i]) ** 2, 0) /
853
+ (history.length - 1)
854
+ ) / (cellCount || 1)
855
+ : 0;
856
+
819
857
  // Avg growth
820
- const avgGrowth = history.length > 1
821
- ? (history[history.length - 1] - history[0]) / history.length
822
- : 0
823
-
858
+ const avgGrowth =
859
+ history.length > 1
860
+ ? (history[history.length - 1] - history[0]) / history.length
861
+ : 0;
862
+
824
863
  // Topology
825
- let numRegions = 1
826
- let largestRegionSize = cellCount
827
- let borderCellCount = 0
828
- let playerCompactness = 0
829
-
864
+ let numRegions = 1;
865
+ let largestRegionSize = cellCount;
866
+ let borderCellCount = 0;
867
+ let playerCompactness = 0;
868
+
830
869
  if (calculateTopology && cellCount > 0) {
831
- const playerCells = new Set<number>()
870
+ const playerCells = new Set<number>();
832
871
  cells.forEach((cell, idx) => {
833
- if (cell.owner === playerId) playerCells.add(idx)
834
- })
835
-
872
+ if (cell.owner === playerId) playerCells.add(idx);
873
+ });
874
+
836
875
  // Count regions using BFS
837
- const visited = new Set<number>()
838
- const regionSizes: number[] = []
839
-
876
+ const visited = new Set<number>();
877
+ const regionSizes: number[] = [];
878
+
840
879
  for (const cellIdx of playerCells) {
841
- if (visited.has(cellIdx)) continue
842
-
843
- const queue = [cellIdx]
844
- visited.add(cellIdx)
845
- let regionSize = 0
846
-
880
+ if (visited.has(cellIdx)) continue;
881
+
882
+ const queue = [cellIdx];
883
+ visited.add(cellIdx);
884
+ let regionSize = 0;
885
+
847
886
  while (queue.length > 0) {
848
- const current = queue.shift()!
849
- regionSize++
850
-
887
+ const current = queue.shift()!;
888
+ regionSize++;
889
+
851
890
  for (const neighbor of getNeighbors(current)) {
852
891
  if (playerCells.has(neighbor) && !visited.has(neighbor)) {
853
- visited.add(neighbor)
854
- queue.push(neighbor)
892
+ visited.add(neighbor);
893
+ queue.push(neighbor);
855
894
  }
856
895
  }
857
896
  }
858
-
859
- regionSizes.push(regionSize)
897
+
898
+ regionSizes.push(regionSize);
860
899
  }
861
-
862
- numRegions = regionSizes.length
863
- largestRegionSize = Math.max(...regionSizes, 0)
864
-
900
+
901
+ numRegions = regionSizes.length;
902
+ largestRegionSize = Math.max(...regionSizes, 0);
903
+
865
904
  // Border cells
866
905
  for (const cellIdx of playerCells) {
867
- const neighbors = getNeighbors(cellIdx)
868
- if (neighbors.some(n => !playerCells.has(n))) {
869
- borderCellCount++
906
+ const neighbors = getNeighbors(cellIdx);
907
+ if (neighbors.some((n) => !playerCells.has(n))) {
908
+ borderCellCount++;
870
909
  }
871
910
  }
872
-
873
- // Compactness
874
- playerCompactness = compactness(playerCells, getNeighbors)
911
+
912
+ // Compactness - compactness expects (area: number, perimeter: number)
913
+ const area = playerCells.size;
914
+ const perimeter = borderCellCount;
915
+ playerCompactness = compactness(area, perimeter);
875
916
  }
876
-
917
+
877
918
  // Lead over second
878
- const leadOverSecond = rankIdx === 0 && sortedBySize.length > 1
879
- ? cellCount - (territoryCounts.get(sortedBySize[1]) ?? 0)
880
- : null
881
-
919
+ const leadOverSecond =
920
+ rankIdx === 0 && sortedBySize.length > 1
921
+ ? cellCount - (territoryCounts.get(sortedBySize[1]) ?? 0)
922
+ : null;
923
+
882
924
  // Turns until overtake (for non-leaders)
883
- let turnsUntilOvertake: number | null = null
925
+ let turnsUntilOvertake: number | null = null;
884
926
  if (rankIdx > 0 && forecastNext10.length > 0) {
885
- const leaderHistory = territoryHistory.get(sortedBySize[0]) ?? []
927
+ const leaderHistory = territoryHistory.get(sortedBySize[0]) ?? [];
886
928
  if (leaderHistory.length > 2) {
887
- const { forecast: leaderForecast } = doubleExponentialSmoothing(leaderHistory, 0.3, 0.1)
888
- const leaderPred = leaderForecast(forecastHorizon)
889
-
890
- for (let t = 0; t < forecastHorizon; t++) {
891
- if (forecastNext10[t] > leaderPred[t]) {
892
- turnsUntilOvertake = t + 1
893
- break
929
+ const leaderForecast = doubleExponentialSmoothing(
930
+ leaderHistory,
931
+ 0.3,
932
+ 0.1
933
+ );
934
+ const leaderPred =
935
+ leaderForecast[leaderForecast.length - 1] ??
936
+ leaderHistory[leaderHistory.length - 1] ??
937
+ 0;
938
+
939
+ for (let t = 0; t < forecastHorizon && t < forecastNext10.length; t++) {
940
+ if (forecastNext10[t] > leaderPred) {
941
+ turnsUntilOvertake = t + 1;
942
+ break;
894
943
  }
895
944
  }
896
945
  }
897
946
  }
898
-
947
+
899
948
  playerSnapshots.push({
900
949
  id: playerId,
901
950
  cellCount,
@@ -903,7 +952,12 @@ export function generateSnapshot(
903
952
  rank: rankIdx + 1,
904
953
  historyLength: history.length,
905
954
  history: includeFullHistory ? [...history] : history.slice(-20),
906
- recentTrend: trend?.direction === 'increasing' ? 'growing' : trend?.direction === 'decreasing' ? 'shrinking' : 'stable',
955
+ recentTrend:
956
+ trend?.direction === 'increasing'
957
+ ? 'growing'
958
+ : trend?.direction === 'decreasing'
959
+ ? 'shrinking'
960
+ : 'stable',
907
961
  trendSlope: trend?.slope ?? 0,
908
962
  trendConfidence: trend?.rSquared ?? 0,
909
963
  winProbability: winProb,
@@ -911,321 +965,424 @@ export function generateSnapshot(
911
965
  forecastNext10,
912
966
  kalmanEstimate,
913
967
  kalmanUncertainty,
914
- conquestRate: conquestResult.pointEstimate,
915
- conquestRateCI: conquestResult.credibleInterval,
968
+ conquestRate: conquestRate,
969
+ conquestRateCI: conquestRateCI,
916
970
  avgGrowthPerTurn: avgGrowth,
917
971
  volatility,
918
972
  numRegions,
919
973
  largestRegionSize,
920
974
  borderCellCount,
921
975
  compactness: playerCompactness,
922
- sparklineAscii: sparkline(history.slice(-30), 20),
976
+ sparklineAscii: sparkline(history.slice(-30)),
923
977
  sparklineSvgPath: sparklineSvg(history.slice(-30), 100, 30),
924
978
  leadOverSecond,
925
- turnsUntilOvertake
926
- })
979
+ turnsUntilOvertake,
980
+ });
927
981
  }
928
-
982
+
929
983
  // Inequality metrics
930
- const values = Array.from(territoryCounts.values())
931
- const gini = giniCoefficient(values)
932
- const theil = theilIndex(values)
933
- const atkinson = atkinsonIndex(values)
934
- const herfindahl = herfindahlIndex(values)
935
- const pareto = paretoRatio(values, 0.2)
936
- const zipf = zipfCoefficient(values)
937
-
938
- let inequalityInterpretation: string
939
- if (gini < 0.2) inequalityInterpretation = 'Highly balanced - fierce competition'
940
- else if (gini < 0.4) inequalityInterpretation = 'Moderately balanced'
941
- else if (gini < 0.6) inequalityInterpretation = 'Emerging dominance'
942
- else if (gini < 0.8) inequalityInterpretation = 'Clear leader emerging'
943
- else inequalityInterpretation = 'Near monopoly - game almost decided'
944
-
984
+ const values = Array.from(territoryCounts.values());
985
+ const gini = giniCoefficient(values);
986
+ const theil = theilIndex(values);
987
+ const atkinson = atkinsonIndex(values, 0.5); // epsilon = 0.5 for standard calculation
988
+ const herfindahl = herfindahlIndex(values);
989
+ const pareto = paretoRatio(values, 0.2);
990
+ const zipf = zipfCoefficient(values);
991
+
992
+ let inequalityInterpretation: string;
993
+ if (gini < 0.2)
994
+ inequalityInterpretation = 'Highly balanced - fierce competition';
995
+ else if (gini < 0.4) inequalityInterpretation = 'Moderately balanced';
996
+ else if (gini < 0.6) inequalityInterpretation = 'Emerging dominance';
997
+ else if (gini < 0.8) inequalityInterpretation = 'Clear leader emerging';
998
+ else inequalityInterpretation = 'Near monopoly - game almost decided';
999
+
945
1000
  // Diversity metrics
946
- const shannon = shannonEntropy(values)
947
- const normalized = normalizedEntropy(values)
948
- const renyi = renyiEntropy(values)
949
- const tsallis = tsallisEntropy(values)
950
-
951
- let diversityInterpretation: string
952
- if (normalized > 0.9) diversityInterpretation = 'Maximum diversity - all players equal'
953
- else if (normalized > 0.7) diversityInterpretation = 'High diversity'
954
- else if (normalized > 0.5) diversityInterpretation = 'Moderate diversity'
955
- else if (normalized > 0.3) diversityInterpretation = 'Low diversity - consolidation'
956
- else diversityInterpretation = 'Minimal diversity - game nearly over'
957
-
1001
+ const shannon = shannonEntropy(values);
1002
+ const normalized = normalizedEntropy(values);
1003
+ const renyi = renyiEntropy(values, 2); // alpha = 2 for standard Renyi entropy
1004
+ const tsallis = tsallisEntropy(values, 2); // q = 2 for standard Tsallis entropy
1005
+
1006
+ let diversityInterpretation: string;
1007
+ if (normalized > 0.9)
1008
+ diversityInterpretation = 'Maximum diversity - all players equal';
1009
+ else if (normalized > 0.7) diversityInterpretation = 'High diversity';
1010
+ else if (normalized > 0.5) diversityInterpretation = 'Moderate diversity';
1011
+ else if (normalized > 0.3)
1012
+ diversityInterpretation = 'Low diversity - consolidation';
1013
+ else diversityInterpretation = 'Minimal diversity - game nearly over';
1014
+
958
1015
  // Distribution metrics
959
- const sorted = [...values].sort((a, b) => a - b)
960
- const mean = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
961
- const median = values.length > 0
962
- ? (values.length % 2 === 0
963
- ? (sorted[values.length / 2 - 1] + sorted[values.length / 2]) / 2
964
- : sorted[Math.floor(values.length / 2)])
965
- : 0
966
- const variance = values.length > 0
967
- ? values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length
968
- : 0
969
- const stdDev = Math.sqrt(variance)
970
-
971
- let m3 = 0, m4 = 0
1016
+ const sorted = [...values].sort((a, b) => a - b);
1017
+ const mean =
1018
+ values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
1019
+ const median =
1020
+ values.length > 0
1021
+ ? values.length % 2 === 0
1022
+ ? (sorted[values.length / 2 - 1] + sorted[values.length / 2]) / 2
1023
+ : sorted[Math.floor(values.length / 2)]
1024
+ : 0;
1025
+ const variance =
1026
+ values.length > 0
1027
+ ? values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length
1028
+ : 0;
1029
+ const stdDev = Math.sqrt(variance);
1030
+
1031
+ let m3 = 0,
1032
+ m4 = 0;
972
1033
  for (const v of values) {
973
- const diff = v - mean
974
- m3 += diff ** 3
975
- m4 += diff ** 4
1034
+ const diff = v - mean;
1035
+ m3 += diff ** 3;
1036
+ m4 += diff ** 4;
976
1037
  }
977
- const skewness = values.length > 0 && stdDev > 0
978
- ? (m3 / values.length) / (stdDev ** 3)
979
- : 0
980
- const kurtosis = values.length > 0 && stdDev > 0
981
- ? (m4 / values.length) / (stdDev ** 4) - 3
982
- : 0
983
-
984
- const q1 = sorted[Math.floor(sorted.length * 0.25)] ?? 0
985
- const q3 = sorted[Math.floor(sorted.length * 0.75)] ?? 0
986
-
987
- // Game state indices
988
- const dominance = probability.dominanceIndex
989
- const volatility = probability.volatilityIndex
990
- const predictability = probability.predictability
991
-
1038
+ const skewness =
1039
+ values.length > 0 && stdDev > 0 ? m3 / values.length / stdDev ** 3 : 0;
1040
+ const kurtosis =
1041
+ values.length > 0 && stdDev > 0 ? m4 / values.length / stdDev ** 4 - 3 : 0;
1042
+
1043
+ const q1 = sorted[Math.floor(sorted.length * 0.25)] ?? 0;
1044
+ const q3 = sorted[Math.floor(sorted.length * 0.75)] ?? 0;
1045
+
1046
+ // Game state indices - ProbabilitySnapshot doesn't have these, calculate from data
1047
+ const topPlayerShare =
1048
+ sortedBySize.length > 0
1049
+ ? (territoryCounts.get(sortedBySize[0]) ?? 0) / occupiedCells
1050
+ : 0;
1051
+ const dominance = topPlayerShare; // Simplified dominance index
1052
+
992
1053
  // Competitiveness: inverse of lead
993
- const topTwoShare = sortedBySize.length >= 2
994
- ? ((territoryCounts.get(sortedBySize[0]) ?? 0) + (territoryCounts.get(sortedBySize[1]) ?? 0)) / occupiedCells
995
- : 1
996
- const competitiveness = sortedBySize.length >= 2
997
- ? 1 - Math.abs(
998
- (territoryCounts.get(sortedBySize[0]) ?? 0) - (territoryCounts.get(sortedBySize[1]) ?? 0)
999
- ) / occupiedCells
1000
- : 0
1001
-
1054
+ const topTwoShare =
1055
+ sortedBySize.length >= 2
1056
+ ? ((territoryCounts.get(sortedBySize[0]) ?? 0) +
1057
+ (territoryCounts.get(sortedBySize[1]) ?? 0)) /
1058
+ occupiedCells
1059
+ : 1;
1060
+ const competitiveness =
1061
+ sortedBySize.length >= 2
1062
+ ? 1 -
1063
+ Math.abs(
1064
+ (territoryCounts.get(sortedBySize[0]) ?? 0) -
1065
+ (territoryCounts.get(sortedBySize[1]) ?? 0)
1066
+ ) /
1067
+ occupiedCells
1068
+ : 0;
1069
+
1002
1070
  // Stability: inverse of avg change rate
1003
- let totalChangeRate = 0
1004
- let historyCount = 0
1071
+ let totalChangeRate = 0;
1072
+ let historyCount = 0;
1005
1073
  for (const [, history] of territoryHistory) {
1006
1074
  if (history.length > 1) {
1007
- const changes = history.slice(1).map((v, i) => Math.abs(v - history[i]))
1008
- const avgChange = changes.reduce((a, b) => a + b, 0) / changes.length
1009
- totalChangeRate += avgChange / (history[history.length - 1] || 1)
1010
- historyCount++
1075
+ const changes = history.slice(1).map((v, i) => Math.abs(v - history[i]));
1076
+ const avgChange = changes.reduce((a, b) => a + b, 0) / changes.length;
1077
+ totalChangeRate += avgChange / (history[history.length - 1] || 1);
1078
+ historyCount++;
1011
1079
  }
1012
1080
  }
1013
- const stability = historyCount > 0 ? Math.max(0, 1 - totalChangeRate / historyCount) : 0.5
1014
-
1081
+ const stability =
1082
+ historyCount > 0 ? Math.max(0, 1 - totalChangeRate / historyCount) : 0.5;
1083
+
1015
1084
  // Predictions
1016
- const likelyWinner = probability.estimatedTurnsToVictory !== null ? sortedBySize[0] : null
1017
- const winnerConfidence = probability.overallConfidence
1018
- const estimatedTurnsToVictory = probability.estimatedTurnsToVictory
1019
-
1020
- const isEndgame = gini > 0.7 || (
1021
- sortedBySize.length >= 2 &&
1022
- (territoryCounts.get(sortedBySize[0]) ?? 0) > occupiedCells * 0.6
1023
- )
1024
-
1025
- const secondPlaceChallenger = sortedBySize.length >= 2 ? sortedBySize[1] : null
1026
-
1027
- // Comeback possibility based on volatility and current gap
1028
- const comebackPossibility = sortedBySize.length >= 2
1029
- ? Math.min(1, volatility + (1 - dominance)) * (1 - winnerConfidence)
1030
- : 0
1031
-
1032
- // Topology summary
1033
- let totalRegions = 0
1034
- let totalBorderCells = 0
1035
- let compactnessSum = 0
1036
-
1037
- for (const player of playerSnapshots) {
1038
- totalRegions += player.numRegions
1039
- totalBorderCells += player.borderCellCount
1040
- compactnessSum += player.compactness
1085
+ // Calculate estimated turns to victory from forecast data
1086
+ const likelyWinner = sortedBySize.length > 0 ? sortedBySize[0] : null;
1087
+ const winnerConfidence = topPlayerShare; // Simplified confidence
1088
+ // Estimate turns to victory based on forecast (simplified)
1089
+ let estimatedTurnsToVictory: number | null = null;
1090
+ if (sortedBySize.length > 0 && territoryHistory.has(sortedBySize[0])) {
1091
+ const leaderHistory = territoryHistory.get(sortedBySize[0]) ?? [];
1092
+ if (leaderHistory.length > 0) {
1093
+ const currentShare =
1094
+ (territoryCounts.get(sortedBySize[0]) ?? 0) / occupiedCells;
1095
+ const targetShare = 0.5; // 50% to win
1096
+ if (currentShare < targetShare && leaderHistory.length > 1) {
1097
+ const growthRate =
1098
+ (leaderHistory[leaderHistory.length - 1] - leaderHistory[0]) /
1099
+ leaderHistory.length;
1100
+ if (growthRate > 0) {
1101
+ estimatedTurnsToVictory = Math.ceil(
1102
+ (targetShare * occupiedCells -
1103
+ (territoryCounts.get(sortedBySize[0]) ?? 0)) /
1104
+ growthRate
1105
+ );
1106
+ }
1107
+ }
1108
+ }
1041
1109
  }
1042
-
1043
- const averageRegionSize = totalRegions > 0 ? occupiedCells / totalRegions : 0
1044
- const territoryFragmentation = playerCount > 0 ? (totalRegions - playerCount) / (occupiedCells || 1) : 0
1045
- const borderCellPercentage = occupiedCells > 0 ? totalBorderCells / occupiedCells : 0
1046
- const avgCompactness = playerCount > 0 ? compactnessSum / playerCount : 0
1047
-
1110
+
1111
+ const isEndgame =
1112
+ gini > 0.7 ||
1113
+ (sortedBySize.length >= 2 &&
1114
+ (territoryCounts.get(sortedBySize[0]) ?? 0) > occupiedCells * 0.6);
1115
+
1116
+ const secondPlaceChallenger =
1117
+ sortedBySize.length >= 2 ? sortedBySize[1] : null;
1118
+
1119
+ // Calculate volatility early for use in comebackPossibility
1048
1120
  // Time series insights
1049
- let overallTrend: 'convergent' | 'divergent' | 'cyclical' | 'chaotic' = 'chaotic'
1050
-
1121
+ let overallTrend: 'convergent' | 'divergent' | 'cyclical' | 'chaotic' =
1122
+ 'chaotic';
1123
+
1051
1124
  // Check if shares are converging or diverging
1052
- const recentVariances: number[] = []
1053
- const historyLen = Math.min(...Array.from(territoryHistory.values()).map(h => h.length))
1054
-
1125
+ const recentVariances: number[] = [];
1126
+ const historyLen = Math.min(
1127
+ ...Array.from(territoryHistory.values()).map((h) => h.length)
1128
+ );
1129
+
1055
1130
  for (let t = Math.max(0, historyLen - 10); t < historyLen; t++) {
1056
- const sharesAtT = players.map(p => {
1057
- const h = territoryHistory.get(p) ?? []
1058
- return h[t] ?? 0
1059
- })
1060
- const meanAtT = sharesAtT.reduce((a, b) => a + b, 0) / sharesAtT.length
1061
- const varAtT = sharesAtT.reduce((sum, s) => sum + (s - meanAtT) ** 2, 0) / sharesAtT.length
1062
- recentVariances.push(varAtT)
1131
+ const sharesAtT = players.map((p) => {
1132
+ const h = territoryHistory.get(p) ?? [];
1133
+ return h[t] ?? 0;
1134
+ });
1135
+ const meanAtT = sharesAtT.reduce((a, b) => a + b, 0) / sharesAtT.length;
1136
+ const varAtT =
1137
+ sharesAtT.reduce((sum, s) => sum + (s - meanAtT) ** 2, 0) /
1138
+ sharesAtT.length;
1139
+ recentVariances.push(varAtT);
1063
1140
  }
1064
-
1141
+
1142
+ // Calculate volatility and predictability from recentVariances
1143
+ const volatility =
1144
+ recentVariances.length > 0
1145
+ ? Math.sqrt(
1146
+ recentVariances.reduce((a, b) => a + b, 0) / recentVariances.length
1147
+ )
1148
+ : 0;
1149
+ const predictability = 1 - Math.min(1, volatility); // Simplified predictability (inverse of volatility)
1150
+
1151
+ // Comeback possibility based on volatility and current gap
1152
+ const comebackPossibility =
1153
+ sortedBySize.length >= 2
1154
+ ? Math.min(1, volatility + (1 - dominance)) * (1 - winnerConfidence)
1155
+ : 0;
1156
+
1157
+ // Topology summary
1158
+ let totalRegions = 0;
1159
+ let totalBorderCells = 0;
1160
+ let compactnessSum = 0;
1161
+
1162
+ for (const player of playerSnapshots) {
1163
+ totalRegions += player.numRegions;
1164
+ totalBorderCells += player.borderCellCount;
1165
+ compactnessSum += player.compactness;
1166
+ }
1167
+
1168
+ const averageRegionSize = totalRegions > 0 ? occupiedCells / totalRegions : 0;
1169
+ const territoryFragmentation =
1170
+ playerCount > 0 ? (totalRegions - playerCount) / (occupiedCells || 1) : 0;
1171
+ const borderCellPercentage =
1172
+ occupiedCells > 0 ? totalBorderCells / occupiedCells : 0;
1173
+ const avgCompactness = playerCount > 0 ? compactnessSum / playerCount : 0;
1174
+
1065
1175
  if (recentVariances.length > 3) {
1066
- const varianceTrend = detectTrend(recentVariances)
1067
- if (varianceTrend.direction === 'decreasing' && varianceTrend.rSquared > 0.5) {
1068
- overallTrend = 'convergent'
1069
- } else if (varianceTrend.direction === 'increasing' && varianceTrend.rSquared > 0.5) {
1070
- overallTrend = 'divergent'
1071
- } else if (varianceTrend.rSquared < 0.2) {
1072
- overallTrend = 'chaotic'
1176
+ const varianceTrend = detectTrend(recentVariances);
1177
+ const rSquared = varianceTrend.rSquared ?? 0;
1178
+ if (varianceTrend.direction === 'decreasing' && rSquared > 0.5) {
1179
+ overallTrend = 'convergent';
1180
+ } else if (varianceTrend.direction === 'increasing' && rSquared > 0.5) {
1181
+ overallTrend = 'divergent';
1182
+ } else if (rSquared < 0.2) {
1183
+ overallTrend = 'chaotic';
1073
1184
  } else {
1074
- overallTrend = 'cyclical'
1185
+ overallTrend = 'cyclical';
1075
1186
  }
1076
1187
  }
1077
-
1188
+
1078
1189
  // Overall change points
1079
- const overallHistory = Array.from(territoryHistory.values())[0] ?? []
1080
- const changePoints = detectChangePoints(overallHistory, 2.0)
1081
-
1190
+ const overallHistory = Array.from(territoryHistory.values())[0] ?? [];
1191
+ const changePoints = detectChangePoints(overallHistory);
1192
+
1082
1193
  // Trend strength
1083
- const overallHistoryTrend = overallHistory.length > 3 ? detectTrend(overallHistory) : null
1084
- const trendStrength = overallHistoryTrend?.rSquared ?? 0
1085
-
1194
+ const overallHistoryTrend =
1195
+ overallHistory.length > 3 ? detectTrend(overallHistory) : null;
1196
+ const trendStrength = overallHistoryTrend?.rSquared ?? 0;
1197
+
1086
1198
  // Autocorrelation (lag 1)
1087
- let autocorrelation = 0
1199
+ let autocorrelation = 0;
1088
1200
  if (overallHistory.length > 5) {
1089
- const ohMean = overallHistory.reduce((a, b) => a + b, 0) / overallHistory.length
1090
- let num = 0, denom = 0
1201
+ const ohMean =
1202
+ overallHistory.reduce((a, b) => a + b, 0) / overallHistory.length;
1203
+ let num = 0,
1204
+ denom = 0;
1091
1205
  for (let i = 1; i < overallHistory.length; i++) {
1092
- num += (overallHistory[i] - ohMean) * (overallHistory[i - 1] - ohMean)
1206
+ num += (overallHistory[i] - ohMean) * (overallHistory[i - 1] - ohMean);
1093
1207
  }
1094
1208
  for (let i = 0; i < overallHistory.length; i++) {
1095
- denom += (overallHistory[i] - ohMean) ** 2
1209
+ denom += (overallHistory[i] - ohMean) ** 2;
1096
1210
  }
1097
- autocorrelation = denom > 0 ? num / denom : 0
1211
+ autocorrelation = denom > 0 ? num / denom : 0;
1098
1212
  }
1099
-
1213
+
1100
1214
  // Seasonality (simple check)
1101
- const seasonality = false // TODO: implement proper seasonality detection
1102
-
1215
+ const seasonality = false; // TODO: implement proper seasonality detection
1216
+
1103
1217
  // Comparisons
1104
- const vs5TurnsAgo: { [playerId: number]: number } = {}
1105
- const vs10TurnsAgo: { [playerId: number]: number } = {}
1106
-
1218
+ const vs5TurnsAgo: { [playerId: number]: number } = {};
1219
+ const vs10TurnsAgo: { [playerId: number]: number } = {};
1220
+
1107
1221
  for (const [player, history] of territoryHistory) {
1108
- const current = history[history.length - 1] ?? 0
1109
- const fiveAgo = history[history.length - 6] ?? current
1110
- const tenAgo = history[history.length - 11] ?? current
1111
-
1112
- vs5TurnsAgo[player] = current - fiveAgo
1113
- vs10TurnsAgo[player] = current - tenAgo
1222
+ const current = history[history.length - 1] ?? 0;
1223
+ const fiveAgo = history[history.length - 6] ?? current;
1224
+ const tenAgo = history[history.length - 11] ?? current;
1225
+
1226
+ vs5TurnsAgo[player] = current - fiveAgo;
1227
+ vs10TurnsAgo[player] = current - tenAgo;
1114
1228
  }
1115
-
1229
+
1116
1230
  // Divergence from uniform
1117
- const uniformProbs = values.map(() => 1 / values.length)
1118
- const actualProbs = values.map(v => v / (occupiedCells || 1))
1119
- const divergenceFromUniform = klDivergence(actualProbs, uniformProbs)
1120
-
1231
+ const uniformProbs = values.map(() => 1 / values.length);
1232
+ const actualProbs = values.map((v) => v / (occupiedCells || 1));
1233
+ const divergenceFromUniform = klDivergence(actualProbs, uniformProbs);
1234
+
1121
1235
  // Divergence from previous turn
1122
- let divergenceFromPrevious = 0
1236
+ let divergenceFromPrevious = 0;
1123
1237
  if (historyLen > 1) {
1124
- const prevProbs = players.map(p => {
1125
- const h = territoryHistory.get(p) ?? []
1126
- return (h[h.length - 2] ?? 0) / (occupiedCells || 1)
1127
- })
1128
- divergenceFromPrevious = jsDivergence(actualProbs, prevProbs)
1238
+ const prevProbs = players.map((p) => {
1239
+ const h = territoryHistory.get(p) ?? [];
1240
+ return (h[h.length - 2] ?? 0) / (occupiedCells || 1);
1241
+ });
1242
+ divergenceFromPrevious = jsDivergence(actualProbs, prevProbs);
1129
1243
  }
1130
-
1244
+
1131
1245
  // Generate insights
1132
- const insights: string[] = []
1133
-
1246
+ const insights: string[] = [];
1247
+
1134
1248
  if (generateInsights) {
1135
1249
  // Leader insights
1136
1250
  if (sortedBySize.length > 0) {
1137
- const leader = playerSnapshots.find(p => p.rank === 1)!
1138
- const leaderShare = leader.shareOfTotal
1139
-
1251
+ const leader = playerSnapshots.find((p) => p.rank === 1)!;
1252
+ const leaderShare = leader.shareOfTotal;
1253
+
1140
1254
  if (leaderShare > 0.8) {
1141
- insights.push(`🏆 Player ${leader.id} dominates with ${(leaderShare * 100).toFixed(1)}% of territory`)
1255
+ insights.push(
1256
+ `🏆 Player ${leader.id} dominates with ${(leaderShare * 100).toFixed(
1257
+ 1
1258
+ )}% of territory`
1259
+ );
1142
1260
  } else if (leaderShare > 0.5) {
1143
- insights.push(`📈 Player ${leader.id} leads with majority control (${(leaderShare * 100).toFixed(1)}%)`)
1261
+ insights.push(
1262
+ `📈 Player ${leader.id} leads with majority control (${(
1263
+ leaderShare * 100
1264
+ ).toFixed(1)}%)`
1265
+ );
1144
1266
  }
1145
-
1267
+
1146
1268
  if (leader.recentTrend === 'growing' && leader.trendConfidence > 0.7) {
1147
- insights.push(`🚀 Leader's territory growing steadily (slope: ${leader.trendSlope.toFixed(2)}/turn)`)
1148
- } else if (leader.recentTrend === 'shrinking' && leader.trendConfidence > 0.7) {
1149
- insights.push(`⚠️ Leader losing ground - opportunity for challengers!`)
1269
+ insights.push(
1270
+ `🚀 Leader's territory growing steadily (slope: ${leader.trendSlope.toFixed(
1271
+ 2
1272
+ )}/turn)`
1273
+ );
1274
+ } else if (
1275
+ leader.recentTrend === 'shrinking' &&
1276
+ leader.trendConfidence > 0.7
1277
+ ) {
1278
+ insights.push(`⚠️ Leader losing ground - opportunity for challengers!`);
1150
1279
  }
1151
1280
  }
1152
-
1281
+
1153
1282
  // Challenger insights
1154
1283
  if (sortedBySize.length >= 2) {
1155
- const challenger = playerSnapshots.find(p => p.rank === 2)!
1156
-
1284
+ const challenger = playerSnapshots.find((p) => p.rank === 2)!;
1285
+
1157
1286
  if (challenger.winProbability > 0.3) {
1158
- insights.push(`🎯 Player ${challenger.id} has ${(challenger.winProbability * 100).toFixed(1)}% chance of winning`)
1287
+ insights.push(
1288
+ `🎯 Player ${challenger.id} has ${(
1289
+ challenger.winProbability * 100
1290
+ ).toFixed(1)}% chance of winning`
1291
+ );
1159
1292
  }
1160
-
1161
- if (challenger.turnsUntilOvertake !== null && challenger.turnsUntilOvertake < 5) {
1162
- insights.push(`⚡ Player ${challenger.id} could overtake in ~${challenger.turnsUntilOvertake} turns!`)
1293
+
1294
+ if (
1295
+ challenger.turnsUntilOvertake !== null &&
1296
+ challenger.turnsUntilOvertake < 5
1297
+ ) {
1298
+ insights.push(
1299
+ `⚡ Player ${challenger.id} could overtake in ~${challenger.turnsUntilOvertake} turns!`
1300
+ );
1163
1301
  }
1164
1302
  }
1165
-
1303
+
1166
1304
  // Game state insights
1167
1305
  if (isEndgame) {
1168
- insights.push(`🔚 Endgame detected - victory imminent`)
1306
+ insights.push(`🔚 Endgame detected - victory imminent`);
1169
1307
  }
1170
-
1308
+
1171
1309
  if (competitiveness > 0.9) {
1172
- insights.push(`🔥 Extremely close competition - anyone could win!`)
1310
+ insights.push(`🔥 Extremely close competition - anyone could win!`);
1173
1311
  }
1174
-
1312
+
1175
1313
  if (volatility > 0.7) {
1176
- insights.push(`🌊 High volatility - expect rapid changes`)
1314
+ insights.push(`🌊 High volatility - expect rapid changes`);
1177
1315
  } else if (volatility < 0.2) {
1178
- insights.push(`🪨 Low volatility - stable territorial lines`)
1316
+ insights.push(`🪨 Low volatility - stable territorial lines`);
1179
1317
  }
1180
-
1318
+
1181
1319
  if (comebackPossibility > 0.5) {
1182
- insights.push(`🔄 Comeback still possible (${(comebackPossibility * 100).toFixed(0)}% chance)`)
1320
+ insights.push(
1321
+ `🔄 Comeback still possible (${(comebackPossibility * 100).toFixed(
1322
+ 0
1323
+ )}% chance)`
1324
+ );
1183
1325
  }
1184
-
1326
+
1185
1327
  // Topology insights
1186
1328
  if (territoryFragmentation > 0.2) {
1187
- insights.push(`🧩 High fragmentation - territories are scattered`)
1329
+ insights.push(`🧩 High fragmentation - territories are scattered`);
1188
1330
  }
1189
-
1331
+
1190
1332
  if (avgCompactness < 0.3) {
1191
- insights.push(`📏 Territories have irregular borders - vulnerable to attack`)
1333
+ insights.push(
1334
+ `📏 Territories have irregular borders - vulnerable to attack`
1335
+ );
1192
1336
  }
1193
-
1337
+
1194
1338
  // Change point insights
1195
1339
  if (changePoints.length > 0) {
1196
- const recentChangePoint = changePoints[changePoints.length - 1]
1340
+ const recentChangePoint = changePoints[changePoints.length - 1];
1197
1341
  if (turnNumber - recentChangePoint < 5) {
1198
- insights.push(`📊 Recent momentum shift detected at turn ${recentChangePoint}`)
1342
+ insights.push(
1343
+ `📊 Recent momentum shift detected at turn ${recentChangePoint}`
1344
+ );
1199
1345
  }
1200
1346
  }
1201
-
1347
+
1202
1348
  // Trend insights
1203
1349
  if (overallTrend === 'convergent') {
1204
- insights.push(`📉 Territories are converging - expect stalemate or final push`)
1350
+ insights.push(
1351
+ `📉 Territories are converging - expect stalemate or final push`
1352
+ );
1205
1353
  } else if (overallTrend === 'divergent') {
1206
- insights.push(`📈 Gap widening - leader pulling ahead`)
1354
+ insights.push(`📈 Gap widening - leader pulling ahead`);
1207
1355
  }
1208
1356
  }
1209
-
1210
- // Detect anomalies
1211
- const gameAnomalies = detectGameAnomalies(territoryHistory)
1212
-
1357
+
1358
+ // Detect anomalies - detectGameAnomalies expects number[], convert territoryHistory
1359
+ const allHistoryValues: number[] = [];
1360
+ for (const [, history] of territoryHistory) {
1361
+ allHistoryValues.push(...history);
1362
+ }
1363
+ const gameAnomalies = detectGameAnomalies(allHistoryValues);
1364
+
1213
1365
  const anomalies: AnomalySummary = {
1214
1366
  outliers: gameAnomalies,
1215
1367
  hasAnomalies: gameAnomalies.length > 0,
1216
1368
  anomalyCount: gameAnomalies.length,
1217
- mostSevere: gameAnomalies.length > 0
1218
- ? gameAnomalies.reduce((max, a) => a.deviationFactor > (max?.deviationFactor ?? 0) ? a : max, gameAnomalies[0]).type
1219
- : null
1220
- }
1221
-
1369
+ mostSevere:
1370
+ gameAnomalies.length > 0
1371
+ ? gameAnomalies.reduce(
1372
+ (max, a) => (a.severity > (max?.severity ?? 0) ? a : max),
1373
+ gameAnomalies[0]
1374
+ ).type
1375
+ : null,
1376
+ };
1377
+
1222
1378
  // Add anomaly insights
1223
1379
  if (generateInsights && anomalies.hasAnomalies) {
1224
- for (const anomaly of gameAnomalies.slice(0, 3)) { // Top 3 anomalies
1225
- insights.push(`⚠️ Anomaly: ${anomaly.description}`)
1380
+ for (const anomaly of gameAnomalies.slice(0, 3)) {
1381
+ // Top 3 anomalies
1382
+ insights.push(`⚠️ Anomaly: ${anomaly.description}`);
1226
1383
  }
1227
1384
  }
1228
-
1385
+
1229
1386
  return {
1230
1387
  timestamp,
1231
1388
  turnNumber,
@@ -1241,14 +1398,14 @@ export function generateSnapshot(
1241
1398
  herfindahl,
1242
1399
  paretoRatio: pareto.ratioHeld,
1243
1400
  zipfCoefficient: zipf,
1244
- interpretation: inequalityInterpretation
1401
+ interpretation: inequalityInterpretation,
1245
1402
  },
1246
1403
  diversity: {
1247
1404
  shannon,
1248
1405
  normalized,
1249
1406
  renyi,
1250
1407
  tsallis,
1251
- interpretation: diversityInterpretation
1408
+ interpretation: diversityInterpretation,
1252
1409
  },
1253
1410
  distribution: {
1254
1411
  mean,
@@ -1260,14 +1417,14 @@ export function generateSnapshot(
1260
1417
  iqr: q3 - q1,
1261
1418
  min: sorted[0] ?? 0,
1262
1419
  max: sorted[sorted.length - 1] ?? 0,
1263
- range: (sorted[sorted.length - 1] ?? 0) - (sorted[0] ?? 0)
1420
+ range: (sorted[sorted.length - 1] ?? 0) - (sorted[0] ?? 0),
1264
1421
  },
1265
1422
  indices: {
1266
1423
  dominance,
1267
1424
  volatility,
1268
1425
  predictability,
1269
1426
  competitiveness,
1270
- stability
1427
+ stability,
1271
1428
  },
1272
1429
  predictions: {
1273
1430
  likelyWinner,
@@ -1275,7 +1432,7 @@ export function generateSnapshot(
1275
1432
  estimatedTurnsToVictory,
1276
1433
  isEndgame,
1277
1434
  secondPlaceChallenger,
1278
- comebackPossibility
1435
+ comebackPossibility,
1279
1436
  },
1280
1437
  anomalies,
1281
1438
  probability,
@@ -1284,119 +1441,167 @@ export function generateSnapshot(
1284
1441
  averageRegionSize,
1285
1442
  territoryFragmentation,
1286
1443
  borderCellPercentage,
1287
- avgCompactness
1444
+ avgCompactness,
1288
1445
  },
1289
1446
  timeSeries: {
1290
1447
  overallTrend,
1291
1448
  changePoints,
1292
1449
  trendStrength,
1293
1450
  autocorrelation,
1294
- seasonality
1451
+ seasonality,
1295
1452
  },
1296
1453
  comparisons: {
1297
1454
  vs5TurnsAgo,
1298
1455
  vs10TurnsAgo,
1299
1456
  divergenceFromUniform,
1300
- divergenceFromPrevious
1457
+ divergenceFromPrevious,
1301
1458
  },
1302
- insights
1303
- }
1459
+ insights,
1460
+ };
1304
1461
  }
1305
1462
 
1306
1463
  /**
1307
1464
  * Format snapshot as human-readable text
1308
1465
  */
1309
1466
  export function formatSnapshotAsText(snapshot: GameSnapshot): string {
1310
- const lines: string[] = []
1311
-
1312
- lines.push('═══════════════════════════════════════════════════════════')
1313
- lines.push(` GAME SNAPSHOT - Turn ${snapshot.turnNumber}`)
1314
- lines.push('═══════════════════════════════════════════════════════════')
1315
- lines.push('')
1316
-
1317
- lines.push(`📊 Territory: ${snapshot.occupiedCells}/${snapshot.totalCells} cells occupied`)
1318
- lines.push(`👥 Players: ${snapshot.playerCount}`)
1319
- lines.push('')
1320
-
1321
- lines.push('┌─────────────────────────────────────────────────────────┐')
1322
- lines.push('│ PLAYER STANDINGS │')
1323
- lines.push('├─────────────────────────────────────────────────────────┤')
1324
-
1467
+ const lines: string[] = [];
1468
+
1469
+ lines.push('═══════════════════════════════════════════════════════════');
1470
+ lines.push(` GAME SNAPSHOT - Turn ${snapshot.turnNumber}`);
1471
+ lines.push('═══════════════════════════════════════════════════════════');
1472
+ lines.push('');
1473
+
1474
+ lines.push(
1475
+ `📊 Territory: ${snapshot.occupiedCells}/${snapshot.totalCells} cells occupied`
1476
+ );
1477
+ lines.push(`👥 Players: ${snapshot.playerCount}`);
1478
+ lines.push('');
1479
+
1480
+ lines.push('┌─────────────────────────────────────────────────────────┐');
1481
+ lines.push('│ PLAYER STANDINGS │');
1482
+ lines.push('├─────────────────────────────────────────────────────────┤');
1483
+
1325
1484
  for (const player of snapshot.players) {
1326
- const bar = '█'.repeat(Math.ceil(player.shareOfTotal * 20))
1327
- const pad = ' '.repeat(20 - bar.length)
1328
- const trend = player.recentTrend === 'growing' ? '↑' :
1329
- player.recentTrend === 'shrinking' ? '↓' : '→'
1330
-
1331
- lines.push(`│ #${player.rank} Player ${player.id}: ${player.cellCount} cells (${(player.shareOfTotal * 100).toFixed(1)}%)`)
1332
- lines.push(`│ ${bar}${pad} ${trend}`)
1333
- lines.push(`│ Win Prob: ${(player.winProbability * 100).toFixed(1)}% Sparkline: ${player.sparklineAscii}`)
1334
- lines.push('│')
1485
+ const bar = '█'.repeat(Math.ceil(player.shareOfTotal * 20));
1486
+ const pad = ' '.repeat(20 - bar.length);
1487
+ const trend =
1488
+ player.recentTrend === 'growing'
1489
+ ? '↑'
1490
+ : player.recentTrend === 'shrinking'
1491
+ ? '↓'
1492
+ : '→';
1493
+
1494
+ lines.push(
1495
+ `│ #${player.rank} Player ${player.id}: ${player.cellCount} cells (${(
1496
+ player.shareOfTotal * 100
1497
+ ).toFixed(1)}%)`
1498
+ );
1499
+ lines.push(`│ ${bar}${pad} ${trend}`);
1500
+ lines.push(
1501
+ `│ Win Prob: ${(player.winProbability * 100).toFixed(
1502
+ 1
1503
+ )}% Sparkline: ${player.sparklineAscii}`
1504
+ );
1505
+ lines.push('│');
1335
1506
  }
1336
-
1337
- lines.push('└─────────────────────────────────────────────────────────┘')
1338
- lines.push('')
1339
-
1340
- lines.push('┌─────────────────────────────────────────────────────────┐')
1341
- lines.push('│ GAME STATE │')
1342
- lines.push('├─────────────────────────────────────────────────────────┤')
1343
- lines.push(`│ Dominance: ${progressBar(snapshot.indices.dominance)} ${(snapshot.indices.dominance * 100).toFixed(0)}%`)
1344
- lines.push(`│ Volatility: ${progressBar(snapshot.indices.volatility)} ${(snapshot.indices.volatility * 100).toFixed(0)}%`)
1345
- lines.push(`│ Competitiveness:${progressBar(snapshot.indices.competitiveness)} ${(snapshot.indices.competitiveness * 100).toFixed(0)}%`)
1346
- lines.push(`│ Stability: ${progressBar(snapshot.indices.stability)} ${(snapshot.indices.stability * 100).toFixed(0)}%`)
1347
- lines.push(`│ Predictability: ${progressBar(snapshot.indices.predictability)} ${(snapshot.indices.predictability * 100).toFixed(0)}%`)
1348
- lines.push('└─────────────────────────────────────────────────────────┘')
1349
- lines.push('')
1350
-
1351
- lines.push('┌─────────────────────────────────────────────────────────┐')
1352
- lines.push('│ PREDICTIONS │')
1353
- lines.push('├─────────────────────────────────────────────────────────┤')
1354
-
1507
+
1508
+ lines.push('└─────────────────────────────────────────────────────────┘');
1509
+ lines.push('');
1510
+
1511
+ lines.push('┌─────────────────────────────────────────────────────────┐');
1512
+ lines.push('│ GAME STATE │');
1513
+ lines.push('├─────────────────────────────────────────────────────────┤');
1514
+ lines.push(
1515
+ `│ Dominance: ${progressBar(snapshot.indices.dominance)} ${(
1516
+ snapshot.indices.dominance * 100
1517
+ ).toFixed(0)}%`
1518
+ );
1519
+ lines.push(
1520
+ `│ Volatility: ${progressBar(snapshot.indices.volatility)} ${(
1521
+ snapshot.indices.volatility * 100
1522
+ ).toFixed(0)}%`
1523
+ );
1524
+ lines.push(
1525
+ `│ Competitiveness:${progressBar(snapshot.indices.competitiveness)} ${(
1526
+ snapshot.indices.competitiveness * 100
1527
+ ).toFixed(0)}%`
1528
+ );
1529
+ lines.push(
1530
+ `│ Stability: ${progressBar(snapshot.indices.stability)} ${(
1531
+ snapshot.indices.stability * 100
1532
+ ).toFixed(0)}%`
1533
+ );
1534
+ lines.push(
1535
+ `│ Predictability: ${progressBar(snapshot.indices.predictability)} ${(
1536
+ snapshot.indices.predictability * 100
1537
+ ).toFixed(0)}%`
1538
+ );
1539
+ lines.push('└─────────────────────────────────────────────────────────┘');
1540
+ lines.push('');
1541
+
1542
+ lines.push('┌─────────────────────────────────────────────────────────┐');
1543
+ lines.push('│ PREDICTIONS │');
1544
+ lines.push('├─────────────────────────────────────────────────────────┤');
1545
+
1355
1546
  if (snapshot.predictions.likelyWinner !== null) {
1356
- lines.push(`│ Likely Winner: Player ${snapshot.predictions.likelyWinner}`)
1357
- lines.push(`│ Confidence: ${(snapshot.predictions.winnerConfidence * 100).toFixed(1)}%`)
1547
+ lines.push(`│ Likely Winner: Player ${snapshot.predictions.likelyWinner}`);
1548
+ lines.push(
1549
+ `│ Confidence: ${(snapshot.predictions.winnerConfidence * 100).toFixed(
1550
+ 1
1551
+ )}%`
1552
+ );
1358
1553
  if (snapshot.predictions.estimatedTurnsToVictory !== null) {
1359
- lines.push(`│ Est. Victory In: ${snapshot.predictions.estimatedTurnsToVictory} turns`)
1554
+ lines.push(
1555
+ `│ Est. Victory In: ${snapshot.predictions.estimatedTurnsToVictory} turns`
1556
+ );
1360
1557
  }
1361
1558
  } else {
1362
- lines.push('│ No clear winner predicted yet')
1559
+ lines.push('│ No clear winner predicted yet');
1363
1560
  }
1364
-
1561
+
1365
1562
  if (snapshot.predictions.isEndgame) {
1366
- lines.push('│ ⚠️ ENDGAME DETECTED')
1563
+ lines.push('│ ⚠️ ENDGAME DETECTED');
1367
1564
  }
1368
-
1369
- lines.push(`│ Comeback Chance: ${(snapshot.predictions.comebackPossibility * 100).toFixed(0)}%`)
1370
- lines.push('└─────────────────────────────────────────────────────────┘')
1371
- lines.push('')
1372
-
1565
+
1566
+ lines.push(
1567
+ `│ Comeback Chance: ${(
1568
+ snapshot.predictions.comebackPossibility * 100
1569
+ ).toFixed(0)}%`
1570
+ );
1571
+ lines.push('└─────────────────────────────────────────────────────────┘');
1572
+ lines.push('');
1573
+
1373
1574
  if (snapshot.insights.length > 0) {
1374
- lines.push('┌─────────────────────────────────────────────────────────┐')
1375
- lines.push('│ INSIGHTS │')
1376
- lines.push('├─────────────────────────────────────────────────────────┤')
1575
+ lines.push('┌─────────────────────────────────────────────────────────┐');
1576
+ lines.push('│ INSIGHTS │');
1577
+ lines.push('├─────────────────────────────────────────────────────────┤');
1377
1578
  for (const insight of snapshot.insights) {
1378
- lines.push(`│ ${insight}`)
1579
+ lines.push(`│ ${insight}`);
1379
1580
  }
1380
- lines.push('└─────────────────────────────────────────────────────────┘')
1581
+ lines.push('└─────────────────────────────────────────────────────────┘');
1381
1582
  }
1382
-
1383
- return lines.join('\n')
1583
+
1584
+ return lines.join('\n');
1384
1585
  }
1385
1586
 
1386
1587
  function progressBar(value: number, width: number = 20): string {
1387
- const filled = Math.round(value * width)
1388
- const empty = width - filled
1389
- return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'
1588
+ const filled = Math.round(value * width);
1589
+ const empty = width - filled;
1590
+ return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
1390
1591
  }
1391
1592
 
1392
1593
  /**
1393
1594
  * Export snapshot as JSON (removes functions)
1394
1595
  */
1395
1596
  export function exportSnapshotAsJSON(snapshot: GameSnapshot): string {
1396
- return JSON.stringify(snapshot, (key, value) => {
1397
- if (value instanceof Map) {
1398
- return Object.fromEntries(value)
1399
- }
1400
- return value
1401
- }, 2)
1597
+ return JSON.stringify(
1598
+ snapshot,
1599
+ (key, value) => {
1600
+ if (value instanceof Map) {
1601
+ return Object.fromEntries(value);
1602
+ }
1603
+ return value;
1604
+ },
1605
+ 2
1606
+ );
1402
1607
  }