@buley/hexgrid-3d 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +28 -0
- package/LICENSE +39 -0
- package/README.md +291 -0
- package/examples/basic-usage.tsx +52 -0
- package/package.json +65 -0
- package/public/hexgrid-worker.js +1763 -0
- package/rust/Cargo.toml +41 -0
- package/rust/src/lib.rs +740 -0
- package/rust/src/math.rs +574 -0
- package/rust/src/spatial.rs +245 -0
- package/rust/src/statistics.rs +496 -0
- package/src/HexGridEnhanced.ts +16 -0
- package/src/Snapshot.ts +1402 -0
- package/src/adapters.ts +65 -0
- package/src/algorithms/AdvancedStatistics.ts +328 -0
- package/src/algorithms/BayesianStatistics.ts +317 -0
- package/src/algorithms/FlowField.ts +126 -0
- package/src/algorithms/FluidSimulation.ts +99 -0
- package/src/algorithms/GraphAlgorithms.ts +184 -0
- package/src/algorithms/OutlierDetection.ts +391 -0
- package/src/algorithms/ParticleSystem.ts +85 -0
- package/src/algorithms/index.ts +13 -0
- package/src/compat.ts +96 -0
- package/src/components/HexGrid.tsx +31 -0
- package/src/components/NarrationOverlay.tsx +221 -0
- package/src/components/index.ts +2 -0
- package/src/features.ts +125 -0
- package/src/index.ts +30 -0
- package/src/math/HexCoordinates.ts +15 -0
- package/src/math/Matrix4.ts +35 -0
- package/src/math/Quaternion.ts +37 -0
- package/src/math/SpatialIndex.ts +114 -0
- package/src/math/Vector3.ts +69 -0
- package/src/math/index.ts +11 -0
- package/src/note-adapter.ts +124 -0
- package/src/ontology-adapter.ts +77 -0
- package/src/stores/index.ts +1 -0
- package/src/stores/uiStore.ts +85 -0
- package/src/types/index.ts +3 -0
- package/src/types.ts +152 -0
- package/src/utils/image-utils.ts +25 -0
- package/src/wasm/HexGridWasmWrapper.ts +753 -0
- package/src/wasm/index.ts +7 -0
- package/src/workers/hexgrid-math.ts +177 -0
- package/src/workers/hexgrid-worker.worker.ts +1807 -0
- package/tsconfig.json +18 -0
package/src/Snapshot.ts
ADDED
|
@@ -0,0 +1,1402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Snapshot API
|
|
3
|
+
*
|
|
4
|
+
* Single comprehensive API to get ALL statistics, predictions, and insights
|
|
5
|
+
* about the current game state in one easy method call.
|
|
6
|
+
*
|
|
7
|
+
* @module Snapshot
|
|
8
|
+
*
|
|
9
|
+
* @example Basic Usage
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { generateSnapshot } from '@buley/hexgrid-3d'
|
|
12
|
+
*
|
|
13
|
+
* const snapshot = generateSnapshot(cells, history, conquests, getNeighbors)
|
|
14
|
+
*
|
|
15
|
+
* // Access player data
|
|
16
|
+
* console.log(snapshot.players[0].winProbability) // 0.72
|
|
17
|
+
* console.log(snapshot.players[0].sparklineAscii) // "▁▂▃▅▆█"
|
|
18
|
+
*
|
|
19
|
+
* // Access game state
|
|
20
|
+
* console.log(snapshot.indices.dominance) // 0.65
|
|
21
|
+
* console.log(snapshot.predictions.likelyWinner) // 2
|
|
22
|
+
*
|
|
23
|
+
* // Get insights
|
|
24
|
+
* console.log(snapshot.insights)
|
|
25
|
+
* // ["🏆 Player 2 dominates with 65.2% of territory",
|
|
26
|
+
* // "🚀 Leader's territory growing steadily"]
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example Full Response Object
|
|
30
|
+
* ```typescript
|
|
31
|
+
* // GameSnapshot example response:
|
|
32
|
+
* const exampleSnapshot: GameSnapshot = {
|
|
33
|
+
* timestamp: 1705678800000,
|
|
34
|
+
* turnNumber: 45,
|
|
35
|
+
* totalCells: 1000,
|
|
36
|
+
* occupiedCells: 850,
|
|
37
|
+
* playerCount: 4,
|
|
38
|
+
*
|
|
39
|
+
* players: [{
|
|
40
|
+
* id: 2,
|
|
41
|
+
* cellCount: 320,
|
|
42
|
+
* shareOfTotal: 0.376,
|
|
43
|
+
* rank: 1,
|
|
44
|
+
* historyLength: 45,
|
|
45
|
+
* history: [10, 15, 22, 35, ...],
|
|
46
|
+
* recentTrend: 'growing',
|
|
47
|
+
* trendSlope: 4.2,
|
|
48
|
+
* trendConfidence: 0.89,
|
|
49
|
+
* winProbability: 0.72,
|
|
50
|
+
* winCredibleInterval: [0.58, 0.84],
|
|
51
|
+
* forecastNext10: [325, 330, 338, ...],
|
|
52
|
+
* kalmanEstimate: 322.5,
|
|
53
|
+
* kalmanUncertainty: 12.3,
|
|
54
|
+
* conquestRate: 0.15,
|
|
55
|
+
* conquestRateCI: [0.12, 0.18],
|
|
56
|
+
* avgGrowthPerTurn: 6.8,
|
|
57
|
+
* volatility: 0.23,
|
|
58
|
+
* numRegions: 2,
|
|
59
|
+
* largestRegionSize: 290,
|
|
60
|
+
* borderCellCount: 85,
|
|
61
|
+
* compactness: 0.72,
|
|
62
|
+
* sparklineAscii: "▁▂▃▄▅▆▇█",
|
|
63
|
+
* sparklineSvgPath: "M0,30 L10,25 L20,18...",
|
|
64
|
+
* leadOverSecond: 45,
|
|
65
|
+
* turnsUntilOvertake: null
|
|
66
|
+
* }, ...],
|
|
67
|
+
*
|
|
68
|
+
* inequality: {
|
|
69
|
+
* gini: 0.42,
|
|
70
|
+
* theil: 0.35,
|
|
71
|
+
* atkinson: 0.28,
|
|
72
|
+
* herfindahl: 0.31,
|
|
73
|
+
* paretoRatio: 0.72,
|
|
74
|
+
* zipfCoefficient: 1.15,
|
|
75
|
+
* interpretation: "Emerging dominance"
|
|
76
|
+
* },
|
|
77
|
+
*
|
|
78
|
+
* indices: {
|
|
79
|
+
* dominance: 0.65,
|
|
80
|
+
* volatility: 0.35,
|
|
81
|
+
* predictability: 0.72,
|
|
82
|
+
* competitiveness: 0.45,
|
|
83
|
+
* stability: 0.68
|
|
84
|
+
* },
|
|
85
|
+
*
|
|
86
|
+
* predictions: {
|
|
87
|
+
* likelyWinner: 2,
|
|
88
|
+
* winnerConfidence: 0.72,
|
|
89
|
+
* estimatedTurnsToVictory: 25,
|
|
90
|
+
* isEndgame: false,
|
|
91
|
+
* secondPlaceChallenger: 3,
|
|
92
|
+
* comebackPossibility: 0.28
|
|
93
|
+
* },
|
|
94
|
+
*
|
|
95
|
+
* anomalies: {
|
|
96
|
+
* outliers: [{ playerId: 4, type: 'growth_explosion', ... }],
|
|
97
|
+
* hasAnomalies: true,
|
|
98
|
+
* anomalyCount: 1
|
|
99
|
+
* },
|
|
100
|
+
*
|
|
101
|
+
* insights: [
|
|
102
|
+
* "📈 Player 2 leads with majority control (37.6%)",
|
|
103
|
+
* "🚀 Leader's territory growing steadily (slope: 4.2/turn)",
|
|
104
|
+
* "🎯 Player 3 has 22.5% chance of winning"
|
|
105
|
+
* ]
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
import {
|
|
111
|
+
giniCoefficient,
|
|
112
|
+
theilIndex,
|
|
113
|
+
atkinsonIndex,
|
|
114
|
+
paretoRatio,
|
|
115
|
+
zipfCoefficient,
|
|
116
|
+
herfindahlIndex,
|
|
117
|
+
shannonEntropy,
|
|
118
|
+
normalizedEntropy,
|
|
119
|
+
renyiEntropy,
|
|
120
|
+
tsallisEntropy,
|
|
121
|
+
klDivergence,
|
|
122
|
+
jsDivergence,
|
|
123
|
+
bhattacharyyaCoefficient,
|
|
124
|
+
hellingerDistance,
|
|
125
|
+
movingAverage,
|
|
126
|
+
exponentialMovingAverage,
|
|
127
|
+
doubleExponentialSmoothing,
|
|
128
|
+
detectTrend,
|
|
129
|
+
detectChangePoints,
|
|
130
|
+
predictWinner,
|
|
131
|
+
eulerCharacteristic,
|
|
132
|
+
estimateBettiNumbers,
|
|
133
|
+
compactness,
|
|
134
|
+
sparkline,
|
|
135
|
+
sparklineSvg,
|
|
136
|
+
computeTerritoryStats,
|
|
137
|
+
type TerritoryStats
|
|
138
|
+
} from './algorithms/AdvancedStatistics'
|
|
139
|
+
|
|
140
|
+
import {
|
|
141
|
+
BetaDistribution,
|
|
142
|
+
DirichletDistribution,
|
|
143
|
+
NormalDistribution,
|
|
144
|
+
PoissonDistribution,
|
|
145
|
+
ExponentialDistribution,
|
|
146
|
+
MarkovChain,
|
|
147
|
+
KalmanFilter,
|
|
148
|
+
HiddenMarkovModel,
|
|
149
|
+
bayesianABTest,
|
|
150
|
+
bayesFactor,
|
|
151
|
+
mapEstimate,
|
|
152
|
+
learnMarkovChain,
|
|
153
|
+
bootstrapConfidenceInterval,
|
|
154
|
+
monteCarloIntegrate,
|
|
155
|
+
mutualInformation,
|
|
156
|
+
conditionalEntropy,
|
|
157
|
+
normalizedMutualInformation,
|
|
158
|
+
bayesianWinProbability,
|
|
159
|
+
bayesianConquestRate,
|
|
160
|
+
bayesianChangepoint,
|
|
161
|
+
generateProbabilitySnapshot,
|
|
162
|
+
type ProbabilitySnapshot
|
|
163
|
+
} from './algorithms/BayesianStatistics'
|
|
164
|
+
|
|
165
|
+
import {
|
|
166
|
+
detectOutliersZScore,
|
|
167
|
+
detectOutliersModifiedZScore,
|
|
168
|
+
detectOutliersIQR,
|
|
169
|
+
detectGrowthSpikes,
|
|
170
|
+
detectVarianceChanges,
|
|
171
|
+
detectGameAnomalies,
|
|
172
|
+
comprehensiveOutlierAnalysis,
|
|
173
|
+
mahalanobisOutliers,
|
|
174
|
+
localOutlierFactor,
|
|
175
|
+
isolationForest,
|
|
176
|
+
cusumChart,
|
|
177
|
+
ewmaChart,
|
|
178
|
+
type OutlierResult,
|
|
179
|
+
type TimeSeriesAnomaly,
|
|
180
|
+
type GameAnomaly,
|
|
181
|
+
type MultivariateOutlierResult
|
|
182
|
+
} from './algorithms/OutlierDetection'
|
|
183
|
+
|
|
184
|
+
import { findConnectedComponents, analyzeTerritorBoundaries } from './algorithms/GraphAlgorithms'
|
|
185
|
+
|
|
186
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
187
|
+
// UNIFIED SNAPSHOT TYPES
|
|
188
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Player-specific statistics
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* const player: PlayerSnapshot = {
|
|
196
|
+
* id: 1,
|
|
197
|
+
* cellCount: 150,
|
|
198
|
+
* shareOfTotal: 0.25,
|
|
199
|
+
* rank: 2,
|
|
200
|
+
* historyLength: 30,
|
|
201
|
+
* history: [10, 15, 25, 40, 60, 90, 120, 150],
|
|
202
|
+
* recentTrend: 'growing',
|
|
203
|
+
* trendSlope: 5.2,
|
|
204
|
+
* trendConfidence: 0.92,
|
|
205
|
+
* winProbability: 0.35,
|
|
206
|
+
* winCredibleInterval: [0.22, 0.48],
|
|
207
|
+
* forecastNext10: [155, 162, 170, 178, 186, 195, 204, 213, 223, 233],
|
|
208
|
+
* kalmanEstimate: 152.3,
|
|
209
|
+
* kalmanUncertainty: 8.5,
|
|
210
|
+
* conquestRate: 0.12,
|
|
211
|
+
* conquestRateCI: [0.08, 0.16],
|
|
212
|
+
* avgGrowthPerTurn: 4.8,
|
|
213
|
+
* volatility: 0.18,
|
|
214
|
+
* numRegions: 1,
|
|
215
|
+
* largestRegionSize: 150,
|
|
216
|
+
* borderCellCount: 42,
|
|
217
|
+
* compactness: 0.78,
|
|
218
|
+
* sparklineAscii: "▁▂▃▄▅▆▇█",
|
|
219
|
+
* sparklineSvgPath: "M0,30 L12.5,25 L25,18 L37.5,12...",
|
|
220
|
+
* leadOverSecond: null, // not the leader
|
|
221
|
+
* turnsUntilOvertake: 8
|
|
222
|
+
* }
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export interface PlayerSnapshot {
|
|
226
|
+
/** Unique player identifier */
|
|
227
|
+
id: number
|
|
228
|
+
|
|
229
|
+
// Territory
|
|
230
|
+
/** Current number of cells owned */
|
|
231
|
+
cellCount: number
|
|
232
|
+
/** Fraction of total occupied cells (0-1) */
|
|
233
|
+
shareOfTotal: number
|
|
234
|
+
/** Current ranking (1 = leader) */
|
|
235
|
+
rank: number
|
|
236
|
+
|
|
237
|
+
// History
|
|
238
|
+
/** Number of turns of history available */
|
|
239
|
+
historyLength: number
|
|
240
|
+
/** Territory count history (last 20 turns unless includeFullHistory=true) */
|
|
241
|
+
history: number[]
|
|
242
|
+
/** Recent territory trend direction */
|
|
243
|
+
recentTrend: 'growing' | 'shrinking' | 'stable'
|
|
244
|
+
/** Linear regression slope (cells per turn) */
|
|
245
|
+
trendSlope: number
|
|
246
|
+
/** R² confidence in trend (0-1) */
|
|
247
|
+
trendConfidence: number
|
|
248
|
+
|
|
249
|
+
// Predictions
|
|
250
|
+
/** Bayesian probability of winning (0-1) */
|
|
251
|
+
winProbability: number
|
|
252
|
+
/** 95% credible interval for win probability */
|
|
253
|
+
winCredibleInterval: [number, number]
|
|
254
|
+
/** Forecasted territory for next 10 turns */
|
|
255
|
+
forecastNext10: number[]
|
|
256
|
+
/** Kalman filter smoothed estimate */
|
|
257
|
+
kalmanEstimate: number
|
|
258
|
+
/** Kalman filter uncertainty (±) */
|
|
259
|
+
kalmanUncertainty: number
|
|
260
|
+
|
|
261
|
+
// Performance
|
|
262
|
+
/** Bayesian conquest success rate */
|
|
263
|
+
conquestRate: number
|
|
264
|
+
/** 95% credible interval for conquest rate */
|
|
265
|
+
conquestRateCI: [number, number]
|
|
266
|
+
/** Average cells gained per turn */
|
|
267
|
+
avgGrowthPerTurn: number
|
|
268
|
+
/** Territory volatility (coefficient of variation) */
|
|
269
|
+
volatility: number
|
|
270
|
+
|
|
271
|
+
// Topology
|
|
272
|
+
/** Number of disconnected territory regions */
|
|
273
|
+
numRegions: number
|
|
274
|
+
/** Size of largest connected region */
|
|
275
|
+
largestRegionSize: number
|
|
276
|
+
/** Number of cells on territory border */
|
|
277
|
+
borderCellCount: number
|
|
278
|
+
/** Territory shape compactness (0-1, higher = more compact) */
|
|
279
|
+
compactness: number
|
|
280
|
+
|
|
281
|
+
// Sparklines
|
|
282
|
+
/** ASCII sparkline visualization of history */
|
|
283
|
+
sparklineAscii: string
|
|
284
|
+
/** SVG path data for sparkline */
|
|
285
|
+
sparklineSvgPath: string
|
|
286
|
+
|
|
287
|
+
// Relative metrics
|
|
288
|
+
/** Lead over second place (null if not leader) */
|
|
289
|
+
leadOverSecond: number | null
|
|
290
|
+
/** Estimated turns until this player could overtake leader */
|
|
291
|
+
turnsUntilOvertake: number | null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Inequality metrics for territory distribution
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```typescript
|
|
299
|
+
* const inequality: InequalityMetrics = {
|
|
300
|
+
* gini: 0.42, // 0=equal, 1=one player has all
|
|
301
|
+
* theil: 0.35, // 0=equal, higher=more unequal
|
|
302
|
+
* atkinson: 0.28, // Sensitive to lower end of distribution
|
|
303
|
+
* herfindahl: 0.31, // Market concentration (0.25=4 equal players)
|
|
304
|
+
* paretoRatio: 0.72, // Top player's share of total
|
|
305
|
+
* zipfCoefficient: 1.15, // Power law exponent
|
|
306
|
+
* interpretation: "Emerging dominance - one player pulling ahead"
|
|
307
|
+
* }
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
export interface InequalityMetrics {
|
|
311
|
+
gini: number
|
|
312
|
+
theil: number
|
|
313
|
+
atkinson: number
|
|
314
|
+
herfindahl: number
|
|
315
|
+
paretoRatio: number
|
|
316
|
+
zipfCoefficient: number
|
|
317
|
+
interpretation: string
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Diversity/entropy metrics for territory distribution
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```typescript
|
|
325
|
+
* const diversity: DiversityMetrics = {
|
|
326
|
+
* shannon: 1.85, // Bits of information
|
|
327
|
+
* normalized: 0.92, // 0-1, relative to max possible
|
|
328
|
+
* renyi: 1.78, // Generalized entropy (order 2)
|
|
329
|
+
* tsallis: 1.52, // Non-extensive entropy
|
|
330
|
+
* interpretation: "High diversity - competitive game"
|
|
331
|
+
* }
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
export interface DiversityMetrics {
|
|
335
|
+
shannon: number
|
|
336
|
+
normalized: number
|
|
337
|
+
renyi: number
|
|
338
|
+
tsallis: number
|
|
339
|
+
interpretation: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Distribution statistics for territory counts
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```typescript
|
|
347
|
+
* const distribution: DistributionMetrics = {
|
|
348
|
+
* mean: 250,
|
|
349
|
+
* median: 220,
|
|
350
|
+
* stdDev: 85,
|
|
351
|
+
* skewness: 0.65, // Positive = right-skewed
|
|
352
|
+
* kurtosis: 2.8, // 3 = normal distribution
|
|
353
|
+
* coefficientOfVariation: 0.34,
|
|
354
|
+
* iqr: 120, // Interquartile range
|
|
355
|
+
* min: 85,
|
|
356
|
+
* max: 420,
|
|
357
|
+
* range: 335
|
|
358
|
+
* }
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
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
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Game state indices (0-1 scale)
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```typescript
|
|
379
|
+
* const indices: GameIndices = {
|
|
380
|
+
* dominance: 0.65, // One player controls 65% relative
|
|
381
|
+
* volatility: 0.35, // 35% average change per turn
|
|
382
|
+
* predictability: 0.72, // 72% confidence in predictions
|
|
383
|
+
* competitiveness: 0.45, // 45% competitive (55% decided)
|
|
384
|
+
* stability: 0.68 // 68% stable territories
|
|
385
|
+
* }
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
export interface GameIndices {
|
|
389
|
+
/** How dominated by a single player (0=even, 1=total domination) */
|
|
390
|
+
dominance: number
|
|
391
|
+
/** How much territory changes each turn (0=static, 1=chaotic) */
|
|
392
|
+
volatility: number
|
|
393
|
+
/** How predictable outcomes are (0=random, 1=deterministic) */
|
|
394
|
+
predictability: number
|
|
395
|
+
/** How close the competition (0=decided, 1=neck-and-neck) */
|
|
396
|
+
competitiveness: number
|
|
397
|
+
/** How stable territory boundaries are (0=fluid, 1=locked) */
|
|
398
|
+
stability: number
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Game outcome predictions
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```typescript
|
|
406
|
+
* const predictions: GamePredictions = {
|
|
407
|
+
* likelyWinner: 2,
|
|
408
|
+
* winnerConfidence: 0.72,
|
|
409
|
+
* estimatedTurnsToVictory: 25,
|
|
410
|
+
* isEndgame: false,
|
|
411
|
+
* secondPlaceChallenger: 3,
|
|
412
|
+
* comebackPossibility: 0.28
|
|
413
|
+
* }
|
|
414
|
+
* ```
|
|
415
|
+
*/
|
|
416
|
+
export interface GamePredictions {
|
|
417
|
+
/** Player ID most likely to win */
|
|
418
|
+
likelyWinner: number | null
|
|
419
|
+
/** Confidence in winner prediction (0-1) */
|
|
420
|
+
winnerConfidence: number
|
|
421
|
+
/** Estimated turns until victory condition */
|
|
422
|
+
estimatedTurnsToVictory: number | null
|
|
423
|
+
/** Whether game is in endgame phase */
|
|
424
|
+
isEndgame: boolean
|
|
425
|
+
/** Second place player who could challenge */
|
|
426
|
+
secondPlaceChallenger: number | null
|
|
427
|
+
/** Probability of comeback by non-leader */
|
|
428
|
+
comebackPossibility: number
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Territory topology metrics
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```typescript
|
|
436
|
+
* const topology: TopologyMetrics = {
|
|
437
|
+
* totalRegions: 8,
|
|
438
|
+
* averageRegionSize: 125,
|
|
439
|
+
* territoryFragmentation: 0.35,
|
|
440
|
+
* borderCellPercentage: 0.42,
|
|
441
|
+
* avgCompactness: 0.68
|
|
442
|
+
* }
|
|
443
|
+
* ```
|
|
444
|
+
*/
|
|
445
|
+
export interface TopologyMetrics {
|
|
446
|
+
/** Total disconnected regions across all players */
|
|
447
|
+
totalRegions: number
|
|
448
|
+
/** Average cells per region */
|
|
449
|
+
averageRegionSize: number
|
|
450
|
+
/** How fragmented territories are (0=solid, 1=scattered) */
|
|
451
|
+
territoryFragmentation: number
|
|
452
|
+
/** Percentage of cells that are border cells */
|
|
453
|
+
borderCellPercentage: number
|
|
454
|
+
/** Average compactness across all territories */
|
|
455
|
+
avgCompactness: number
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Time series analysis metrics
|
|
460
|
+
*
|
|
461
|
+
* @example
|
|
462
|
+
* ```typescript
|
|
463
|
+
* const timeSeries: TimeSeriesMetrics = {
|
|
464
|
+
* overallTrend: 'divergent', // Players spreading apart
|
|
465
|
+
* changePoints: [12, 28, 45], // Turn numbers where behavior changed
|
|
466
|
+
* trendStrength: 0.78, // Strength of overall trend
|
|
467
|
+
* autocorrelation: 0.65, // How correlated with past values
|
|
468
|
+
* seasonality: false // No periodic patterns detected
|
|
469
|
+
* }
|
|
470
|
+
* ```
|
|
471
|
+
*/
|
|
472
|
+
export interface TimeSeriesMetrics {
|
|
473
|
+
/** Overall trend type */
|
|
474
|
+
overallTrend: 'convergent' | 'divergent' | 'cyclical' | 'chaotic'
|
|
475
|
+
/** Turn numbers where significant changes occurred */
|
|
476
|
+
changePoints: number[]
|
|
477
|
+
/** Strength of overall trend (0-1) */
|
|
478
|
+
trendStrength: number
|
|
479
|
+
/** Autocorrelation coefficient */
|
|
480
|
+
autocorrelation: number
|
|
481
|
+
/** Whether periodic patterns detected */
|
|
482
|
+
seasonality: boolean
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Comparison metrics with past states
|
|
487
|
+
*
|
|
488
|
+
* @example
|
|
489
|
+
* ```typescript
|
|
490
|
+
* const comparisons: ComparisonMetrics = {
|
|
491
|
+
* vs5TurnsAgo: { 1: 15, 2: 8, 3: -12, 4: -11 }, // Territory change
|
|
492
|
+
* vs10TurnsAgo: { 1: 45, 2: 22, 3: -35, 4: -32 },
|
|
493
|
+
* divergenceFromUniform: 0.42, // How far from equal distribution
|
|
494
|
+
* divergenceFromPrevious: 0.08 // How much changed from last turn
|
|
495
|
+
* }
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
export interface ComparisonMetrics {
|
|
499
|
+
/** Territory change per player vs 5 turns ago */
|
|
500
|
+
vs5TurnsAgo: { [playerId: number]: number }
|
|
501
|
+
/** Territory change per player vs 10 turns ago */
|
|
502
|
+
vs10TurnsAgo: { [playerId: number]: number }
|
|
503
|
+
/** KL divergence from uniform distribution */
|
|
504
|
+
divergenceFromUniform: number
|
|
505
|
+
/** JS divergence from previous turn */
|
|
506
|
+
divergenceFromPrevious: number
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Anomaly detection summary
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* ```typescript
|
|
514
|
+
* const anomalies: AnomalySummary = {
|
|
515
|
+
* outliers: [{
|
|
516
|
+
* playerId: 2,
|
|
517
|
+
* type: 'growth_explosion',
|
|
518
|
+
* severity: 3.2,
|
|
519
|
+
* timestamp: 1705678800000,
|
|
520
|
+
* description: 'Unusual territory gain: +45 cells in one turn',
|
|
521
|
+
* metrics: { growth: 45, avgGrowth: 5.2, zscore: 3.2 }
|
|
522
|
+
* }],
|
|
523
|
+
* hasAnomalies: true,
|
|
524
|
+
* anomalyCount: 1,
|
|
525
|
+
* mostSevere: 'growth_explosion'
|
|
526
|
+
* }
|
|
527
|
+
* ```
|
|
528
|
+
*/
|
|
529
|
+
export interface AnomalySummary {
|
|
530
|
+
/** Detected game anomalies */
|
|
531
|
+
outliers: GameAnomaly[]
|
|
532
|
+
/** Whether any anomalies were detected */
|
|
533
|
+
hasAnomalies: boolean
|
|
534
|
+
/** Total count of anomalies */
|
|
535
|
+
anomalyCount: number
|
|
536
|
+
/** Type of most severe anomaly (if any) */
|
|
537
|
+
mostSevere: string | null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Complete game snapshot with all statistics
|
|
542
|
+
*
|
|
543
|
+
* @example Full Response Structure
|
|
544
|
+
* ```typescript
|
|
545
|
+
* const snapshot: GameSnapshot = {
|
|
546
|
+
* timestamp: 1705678800000,
|
|
547
|
+
* turnNumber: 45,
|
|
548
|
+
* totalCells: 1000,
|
|
549
|
+
* occupiedCells: 850,
|
|
550
|
+
* playerCount: 4,
|
|
551
|
+
*
|
|
552
|
+
* players: [
|
|
553
|
+
* { id: 2, cellCount: 320, shareOfTotal: 0.376, rank: 1, ... },
|
|
554
|
+
* { id: 1, cellCount: 210, shareOfTotal: 0.247, rank: 2, ... },
|
|
555
|
+
* { id: 3, cellCount: 180, shareOfTotal: 0.212, rank: 3, ... },
|
|
556
|
+
* { id: 4, cellCount: 140, shareOfTotal: 0.165, rank: 4, ... }
|
|
557
|
+
* ],
|
|
558
|
+
*
|
|
559
|
+
* territoryStats: { ... },
|
|
560
|
+
*
|
|
561
|
+
* inequality: {
|
|
562
|
+
* gini: 0.42,
|
|
563
|
+
* theil: 0.35,
|
|
564
|
+
* atkinson: 0.28,
|
|
565
|
+
* herfindahl: 0.31,
|
|
566
|
+
* paretoRatio: 0.72,
|
|
567
|
+
* zipfCoefficient: 1.15,
|
|
568
|
+
* interpretation: "Emerging dominance"
|
|
569
|
+
* },
|
|
570
|
+
*
|
|
571
|
+
* diversity: {
|
|
572
|
+
* shannon: 1.85,
|
|
573
|
+
* normalized: 0.92,
|
|
574
|
+
* renyi: 1.78,
|
|
575
|
+
* tsallis: 1.52,
|
|
576
|
+
* interpretation: "High diversity"
|
|
577
|
+
* },
|
|
578
|
+
*
|
|
579
|
+
* distribution: {
|
|
580
|
+
* mean: 212.5, median: 195, stdDev: 68.2,
|
|
581
|
+
* skewness: 0.65, kurtosis: 2.8,
|
|
582
|
+
* coefficientOfVariation: 0.32,
|
|
583
|
+
* iqr: 95, min: 140, max: 320, range: 180
|
|
584
|
+
* },
|
|
585
|
+
*
|
|
586
|
+
* indices: {
|
|
587
|
+
* dominance: 0.65,
|
|
588
|
+
* volatility: 0.35,
|
|
589
|
+
* predictability: 0.72,
|
|
590
|
+
* competitiveness: 0.45,
|
|
591
|
+
* stability: 0.68
|
|
592
|
+
* },
|
|
593
|
+
*
|
|
594
|
+
* predictions: {
|
|
595
|
+
* likelyWinner: 2,
|
|
596
|
+
* winnerConfidence: 0.72,
|
|
597
|
+
* estimatedTurnsToVictory: 25,
|
|
598
|
+
* isEndgame: false,
|
|
599
|
+
* secondPlaceChallenger: 1,
|
|
600
|
+
* comebackPossibility: 0.28
|
|
601
|
+
* },
|
|
602
|
+
*
|
|
603
|
+
* anomalies: {
|
|
604
|
+
* outliers: [],
|
|
605
|
+
* hasAnomalies: false,
|
|
606
|
+
* anomalyCount: 0,
|
|
607
|
+
* mostSevere: null
|
|
608
|
+
* },
|
|
609
|
+
*
|
|
610
|
+
* probability: { winProbabilities: Map, ... },
|
|
611
|
+
* topology: { totalRegions: 8, ... },
|
|
612
|
+
* timeSeries: { overallTrend: 'divergent', ... },
|
|
613
|
+
* comparisons: { vs5TurnsAgo: {...}, ... },
|
|
614
|
+
*
|
|
615
|
+
* insights: [
|
|
616
|
+
* "📈 Player 2 leads with 37.6% of territory",
|
|
617
|
+
* "🚀 Leader's territory growing steadily",
|
|
618
|
+
* "🎯 72% confidence in Player 2 victory"
|
|
619
|
+
* ]
|
|
620
|
+
* }
|
|
621
|
+
* ```
|
|
622
|
+
*/
|
|
623
|
+
export interface GameSnapshot {
|
|
624
|
+
// Basic
|
|
625
|
+
/** Unix timestamp when snapshot was generated */
|
|
626
|
+
timestamp: number
|
|
627
|
+
/** Current turn number */
|
|
628
|
+
turnNumber: number
|
|
629
|
+
/** Total cells on the grid */
|
|
630
|
+
totalCells: number
|
|
631
|
+
/** Number of cells with an owner */
|
|
632
|
+
occupiedCells: number
|
|
633
|
+
|
|
634
|
+
// Players
|
|
635
|
+
/** Number of active players */
|
|
636
|
+
playerCount: number
|
|
637
|
+
/** Detailed stats for each player, sorted by rank */
|
|
638
|
+
players: PlayerSnapshot[]
|
|
639
|
+
|
|
640
|
+
// Overall territory stats
|
|
641
|
+
/** Aggregate territory statistics */
|
|
642
|
+
territoryStats: TerritoryStats
|
|
643
|
+
|
|
644
|
+
// Inequality metrics (all players)
|
|
645
|
+
/** Inequality measures for territory distribution */
|
|
646
|
+
inequality: InequalityMetrics
|
|
647
|
+
|
|
648
|
+
// Entropy/diversity
|
|
649
|
+
/** Diversity/entropy measures */
|
|
650
|
+
diversity: DiversityMetrics
|
|
651
|
+
|
|
652
|
+
// Distribution metrics
|
|
653
|
+
/** Statistical distribution of territory counts */
|
|
654
|
+
distribution: DistributionMetrics
|
|
655
|
+
|
|
656
|
+
// Game state indices
|
|
657
|
+
/** Normalized game state indicators */
|
|
658
|
+
indices: GameIndices
|
|
659
|
+
|
|
660
|
+
// Predictions
|
|
661
|
+
/** Game outcome predictions */
|
|
662
|
+
predictions: GamePredictions
|
|
663
|
+
|
|
664
|
+
// Anomaly detection
|
|
665
|
+
/** Detected anomalies and outliers */
|
|
666
|
+
anomalies: AnomalySummary
|
|
667
|
+
|
|
668
|
+
// Probability snapshot (Bayesian)
|
|
669
|
+
/** Full Bayesian probability analysis */
|
|
670
|
+
probability: ProbabilitySnapshot
|
|
671
|
+
|
|
672
|
+
// Topology
|
|
673
|
+
/** Territory topology metrics */
|
|
674
|
+
topology: TopologyMetrics
|
|
675
|
+
|
|
676
|
+
// Time series insights
|
|
677
|
+
/** Time series analysis */
|
|
678
|
+
timeSeries: TimeSeriesMetrics
|
|
679
|
+
|
|
680
|
+
// Comparisons
|
|
681
|
+
/** Comparisons with past states */
|
|
682
|
+
comparisons: ComparisonMetrics
|
|
683
|
+
|
|
684
|
+
// Recommendations / insights
|
|
685
|
+
/** Human-readable insights and observations */
|
|
686
|
+
insights: string[]
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Re-export all types for easy importing
|
|
690
|
+
export type {
|
|
691
|
+
TerritoryStats,
|
|
692
|
+
ProbabilitySnapshot,
|
|
693
|
+
OutlierResult,
|
|
694
|
+
TimeSeriesAnomaly,
|
|
695
|
+
GameAnomaly,
|
|
696
|
+
MultivariateOutlierResult
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
700
|
+
// SNAPSHOT GENERATOR
|
|
701
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
702
|
+
|
|
703
|
+
export interface SnapshotConfig {
|
|
704
|
+
/** Number of turns for forecasting */
|
|
705
|
+
forecastHorizon?: number
|
|
706
|
+
/** Number of Monte Carlo samples */
|
|
707
|
+
monteCarloSamples?: number
|
|
708
|
+
/** Include detailed history */
|
|
709
|
+
includeFullHistory?: boolean
|
|
710
|
+
/** Calculate topology (can be expensive) */
|
|
711
|
+
calculateTopology?: boolean
|
|
712
|
+
/** Generate insights */
|
|
713
|
+
generateInsights?: boolean
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Generate comprehensive snapshot from game state
|
|
718
|
+
*/
|
|
719
|
+
export function generateSnapshot(
|
|
720
|
+
cells: { owner: number; population?: number }[],
|
|
721
|
+
territoryHistory: Map<number, number[]>,
|
|
722
|
+
conquestCounts: Map<number, { successes: number; opportunities: number }>,
|
|
723
|
+
getNeighbors: (cellIndex: number) => number[],
|
|
724
|
+
config: SnapshotConfig = {}
|
|
725
|
+
): GameSnapshot {
|
|
726
|
+
const {
|
|
727
|
+
forecastHorizon = 10,
|
|
728
|
+
monteCarloSamples = 1000,
|
|
729
|
+
includeFullHistory = false,
|
|
730
|
+
calculateTopology = true,
|
|
731
|
+
generateInsights = true
|
|
732
|
+
} = config
|
|
733
|
+
|
|
734
|
+
const timestamp = Date.now()
|
|
735
|
+
const totalCells = cells.length
|
|
736
|
+
|
|
737
|
+
// Count territories
|
|
738
|
+
const territoryCounts = new Map<number, number>()
|
|
739
|
+
for (const cell of cells) {
|
|
740
|
+
if (cell.owner !== 0) {
|
|
741
|
+
territoryCounts.set(cell.owner, (territoryCounts.get(cell.owner) ?? 0) + 1)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
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
|
+
|
|
749
|
+
// Sort by territory size for ranking
|
|
750
|
+
const sortedBySize = [...players].sort((a, b) =>
|
|
751
|
+
(territoryCounts.get(b) ?? 0) - (territoryCounts.get(a) ?? 0)
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
// 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
|
+
|
|
766
|
+
// Compute player snapshots
|
|
767
|
+
const playerSnapshots: PlayerSnapshot[] = []
|
|
768
|
+
|
|
769
|
+
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
|
+
|
|
774
|
+
// 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
|
+
|
|
783
|
+
// Kalman filter
|
|
784
|
+
let kalmanEstimate = cellCount
|
|
785
|
+
let kalmanUncertainty = 0
|
|
786
|
+
if (history.length > 3) {
|
|
787
|
+
const variance = history.slice(1).reduce((sum, v, i) =>
|
|
788
|
+
sum + (v - history[i]) ** 2, 0) / history.length
|
|
789
|
+
|
|
790
|
+
const filter = new KalmanFilter(
|
|
791
|
+
history[0],
|
|
792
|
+
variance || 1,
|
|
793
|
+
(variance || 1) * 0.1,
|
|
794
|
+
(variance || 1) * 0.5
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
for (const measurement of history) {
|
|
798
|
+
filter.step(measurement)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
kalmanEstimate = filter.getState()
|
|
802
|
+
kalmanUncertainty = filter.getUncertainty()
|
|
803
|
+
}
|
|
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
|
+
|
|
813
|
+
// 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
|
+
|
|
819
|
+
// Avg growth
|
|
820
|
+
const avgGrowth = history.length > 1
|
|
821
|
+
? (history[history.length - 1] - history[0]) / history.length
|
|
822
|
+
: 0
|
|
823
|
+
|
|
824
|
+
// Topology
|
|
825
|
+
let numRegions = 1
|
|
826
|
+
let largestRegionSize = cellCount
|
|
827
|
+
let borderCellCount = 0
|
|
828
|
+
let playerCompactness = 0
|
|
829
|
+
|
|
830
|
+
if (calculateTopology && cellCount > 0) {
|
|
831
|
+
const playerCells = new Set<number>()
|
|
832
|
+
cells.forEach((cell, idx) => {
|
|
833
|
+
if (cell.owner === playerId) playerCells.add(idx)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
// Count regions using BFS
|
|
837
|
+
const visited = new Set<number>()
|
|
838
|
+
const regionSizes: number[] = []
|
|
839
|
+
|
|
840
|
+
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
|
+
|
|
847
|
+
while (queue.length > 0) {
|
|
848
|
+
const current = queue.shift()!
|
|
849
|
+
regionSize++
|
|
850
|
+
|
|
851
|
+
for (const neighbor of getNeighbors(current)) {
|
|
852
|
+
if (playerCells.has(neighbor) && !visited.has(neighbor)) {
|
|
853
|
+
visited.add(neighbor)
|
|
854
|
+
queue.push(neighbor)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
regionSizes.push(regionSize)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
numRegions = regionSizes.length
|
|
863
|
+
largestRegionSize = Math.max(...regionSizes, 0)
|
|
864
|
+
|
|
865
|
+
// Border cells
|
|
866
|
+
for (const cellIdx of playerCells) {
|
|
867
|
+
const neighbors = getNeighbors(cellIdx)
|
|
868
|
+
if (neighbors.some(n => !playerCells.has(n))) {
|
|
869
|
+
borderCellCount++
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Compactness
|
|
874
|
+
playerCompactness = compactness(playerCells, getNeighbors)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Lead over second
|
|
878
|
+
const leadOverSecond = rankIdx === 0 && sortedBySize.length > 1
|
|
879
|
+
? cellCount - (territoryCounts.get(sortedBySize[1]) ?? 0)
|
|
880
|
+
: null
|
|
881
|
+
|
|
882
|
+
// Turns until overtake (for non-leaders)
|
|
883
|
+
let turnsUntilOvertake: number | null = null
|
|
884
|
+
if (rankIdx > 0 && forecastNext10.length > 0) {
|
|
885
|
+
const leaderHistory = territoryHistory.get(sortedBySize[0]) ?? []
|
|
886
|
+
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
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
playerSnapshots.push({
|
|
900
|
+
id: playerId,
|
|
901
|
+
cellCount,
|
|
902
|
+
shareOfTotal: occupiedCells > 0 ? cellCount / occupiedCells : 0,
|
|
903
|
+
rank: rankIdx + 1,
|
|
904
|
+
historyLength: history.length,
|
|
905
|
+
history: includeFullHistory ? [...history] : history.slice(-20),
|
|
906
|
+
recentTrend: trend?.direction === 'increasing' ? 'growing' : trend?.direction === 'decreasing' ? 'shrinking' : 'stable',
|
|
907
|
+
trendSlope: trend?.slope ?? 0,
|
|
908
|
+
trendConfidence: trend?.rSquared ?? 0,
|
|
909
|
+
winProbability: winProb,
|
|
910
|
+
winCredibleInterval: winCI as [number, number],
|
|
911
|
+
forecastNext10,
|
|
912
|
+
kalmanEstimate,
|
|
913
|
+
kalmanUncertainty,
|
|
914
|
+
conquestRate: conquestResult.pointEstimate,
|
|
915
|
+
conquestRateCI: conquestResult.credibleInterval,
|
|
916
|
+
avgGrowthPerTurn: avgGrowth,
|
|
917
|
+
volatility,
|
|
918
|
+
numRegions,
|
|
919
|
+
largestRegionSize,
|
|
920
|
+
borderCellCount,
|
|
921
|
+
compactness: playerCompactness,
|
|
922
|
+
sparklineAscii: sparkline(history.slice(-30), 20),
|
|
923
|
+
sparklineSvgPath: sparklineSvg(history.slice(-30), 100, 30),
|
|
924
|
+
leadOverSecond,
|
|
925
|
+
turnsUntilOvertake
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// 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
|
+
|
|
945
|
+
// 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
|
+
|
|
958
|
+
// 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
|
|
972
|
+
for (const v of values) {
|
|
973
|
+
const diff = v - mean
|
|
974
|
+
m3 += diff ** 3
|
|
975
|
+
m4 += diff ** 4
|
|
976
|
+
}
|
|
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
|
+
|
|
992
|
+
// 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
|
+
|
|
1002
|
+
// Stability: inverse of avg change rate
|
|
1003
|
+
let totalChangeRate = 0
|
|
1004
|
+
let historyCount = 0
|
|
1005
|
+
for (const [, history] of territoryHistory) {
|
|
1006
|
+
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++
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const stability = historyCount > 0 ? Math.max(0, 1 - totalChangeRate / historyCount) : 0.5
|
|
1014
|
+
|
|
1015
|
+
// 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
|
|
1041
|
+
}
|
|
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
|
+
|
|
1048
|
+
// Time series insights
|
|
1049
|
+
let overallTrend: 'convergent' | 'divergent' | 'cyclical' | 'chaotic' = 'chaotic'
|
|
1050
|
+
|
|
1051
|
+
// 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
|
+
|
|
1055
|
+
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)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
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'
|
|
1073
|
+
} else {
|
|
1074
|
+
overallTrend = 'cyclical'
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Overall change points
|
|
1079
|
+
const overallHistory = Array.from(territoryHistory.values())[0] ?? []
|
|
1080
|
+
const changePoints = detectChangePoints(overallHistory, 2.0)
|
|
1081
|
+
|
|
1082
|
+
// Trend strength
|
|
1083
|
+
const overallHistoryTrend = overallHistory.length > 3 ? detectTrend(overallHistory) : null
|
|
1084
|
+
const trendStrength = overallHistoryTrend?.rSquared ?? 0
|
|
1085
|
+
|
|
1086
|
+
// Autocorrelation (lag 1)
|
|
1087
|
+
let autocorrelation = 0
|
|
1088
|
+
if (overallHistory.length > 5) {
|
|
1089
|
+
const ohMean = overallHistory.reduce((a, b) => a + b, 0) / overallHistory.length
|
|
1090
|
+
let num = 0, denom = 0
|
|
1091
|
+
for (let i = 1; i < overallHistory.length; i++) {
|
|
1092
|
+
num += (overallHistory[i] - ohMean) * (overallHistory[i - 1] - ohMean)
|
|
1093
|
+
}
|
|
1094
|
+
for (let i = 0; i < overallHistory.length; i++) {
|
|
1095
|
+
denom += (overallHistory[i] - ohMean) ** 2
|
|
1096
|
+
}
|
|
1097
|
+
autocorrelation = denom > 0 ? num / denom : 0
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Seasonality (simple check)
|
|
1101
|
+
const seasonality = false // TODO: implement proper seasonality detection
|
|
1102
|
+
|
|
1103
|
+
// Comparisons
|
|
1104
|
+
const vs5TurnsAgo: { [playerId: number]: number } = {}
|
|
1105
|
+
const vs10TurnsAgo: { [playerId: number]: number } = {}
|
|
1106
|
+
|
|
1107
|
+
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
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// 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
|
+
|
|
1121
|
+
// Divergence from previous turn
|
|
1122
|
+
let divergenceFromPrevious = 0
|
|
1123
|
+
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)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Generate insights
|
|
1132
|
+
const insights: string[] = []
|
|
1133
|
+
|
|
1134
|
+
if (generateInsights) {
|
|
1135
|
+
// Leader insights
|
|
1136
|
+
if (sortedBySize.length > 0) {
|
|
1137
|
+
const leader = playerSnapshots.find(p => p.rank === 1)!
|
|
1138
|
+
const leaderShare = leader.shareOfTotal
|
|
1139
|
+
|
|
1140
|
+
if (leaderShare > 0.8) {
|
|
1141
|
+
insights.push(`🏆 Player ${leader.id} dominates with ${(leaderShare * 100).toFixed(1)}% of territory`)
|
|
1142
|
+
} else if (leaderShare > 0.5) {
|
|
1143
|
+
insights.push(`📈 Player ${leader.id} leads with majority control (${(leaderShare * 100).toFixed(1)}%)`)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
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!`)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Challenger insights
|
|
1154
|
+
if (sortedBySize.length >= 2) {
|
|
1155
|
+
const challenger = playerSnapshots.find(p => p.rank === 2)!
|
|
1156
|
+
|
|
1157
|
+
if (challenger.winProbability > 0.3) {
|
|
1158
|
+
insights.push(`🎯 Player ${challenger.id} has ${(challenger.winProbability * 100).toFixed(1)}% chance of winning`)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (challenger.turnsUntilOvertake !== null && challenger.turnsUntilOvertake < 5) {
|
|
1162
|
+
insights.push(`⚡ Player ${challenger.id} could overtake in ~${challenger.turnsUntilOvertake} turns!`)
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Game state insights
|
|
1167
|
+
if (isEndgame) {
|
|
1168
|
+
insights.push(`🔚 Endgame detected - victory imminent`)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (competitiveness > 0.9) {
|
|
1172
|
+
insights.push(`🔥 Extremely close competition - anyone could win!`)
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (volatility > 0.7) {
|
|
1176
|
+
insights.push(`🌊 High volatility - expect rapid changes`)
|
|
1177
|
+
} else if (volatility < 0.2) {
|
|
1178
|
+
insights.push(`🪨 Low volatility - stable territorial lines`)
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (comebackPossibility > 0.5) {
|
|
1182
|
+
insights.push(`🔄 Comeback still possible (${(comebackPossibility * 100).toFixed(0)}% chance)`)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Topology insights
|
|
1186
|
+
if (territoryFragmentation > 0.2) {
|
|
1187
|
+
insights.push(`🧩 High fragmentation - territories are scattered`)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (avgCompactness < 0.3) {
|
|
1191
|
+
insights.push(`📏 Territories have irregular borders - vulnerable to attack`)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Change point insights
|
|
1195
|
+
if (changePoints.length > 0) {
|
|
1196
|
+
const recentChangePoint = changePoints[changePoints.length - 1]
|
|
1197
|
+
if (turnNumber - recentChangePoint < 5) {
|
|
1198
|
+
insights.push(`📊 Recent momentum shift detected at turn ${recentChangePoint}`)
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Trend insights
|
|
1203
|
+
if (overallTrend === 'convergent') {
|
|
1204
|
+
insights.push(`📉 Territories are converging - expect stalemate or final push`)
|
|
1205
|
+
} else if (overallTrend === 'divergent') {
|
|
1206
|
+
insights.push(`📈 Gap widening - leader pulling ahead`)
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Detect anomalies
|
|
1211
|
+
const gameAnomalies = detectGameAnomalies(territoryHistory)
|
|
1212
|
+
|
|
1213
|
+
const anomalies: AnomalySummary = {
|
|
1214
|
+
outliers: gameAnomalies,
|
|
1215
|
+
hasAnomalies: gameAnomalies.length > 0,
|
|
1216
|
+
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
|
+
|
|
1222
|
+
// Add anomaly insights
|
|
1223
|
+
if (generateInsights && anomalies.hasAnomalies) {
|
|
1224
|
+
for (const anomaly of gameAnomalies.slice(0, 3)) { // Top 3 anomalies
|
|
1225
|
+
insights.push(`⚠️ Anomaly: ${anomaly.description}`)
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
return {
|
|
1230
|
+
timestamp,
|
|
1231
|
+
turnNumber,
|
|
1232
|
+
totalCells,
|
|
1233
|
+
occupiedCells,
|
|
1234
|
+
playerCount,
|
|
1235
|
+
players: playerSnapshots,
|
|
1236
|
+
territoryStats,
|
|
1237
|
+
inequality: {
|
|
1238
|
+
gini,
|
|
1239
|
+
theil,
|
|
1240
|
+
atkinson,
|
|
1241
|
+
herfindahl,
|
|
1242
|
+
paretoRatio: pareto.ratioHeld,
|
|
1243
|
+
zipfCoefficient: zipf,
|
|
1244
|
+
interpretation: inequalityInterpretation
|
|
1245
|
+
},
|
|
1246
|
+
diversity: {
|
|
1247
|
+
shannon,
|
|
1248
|
+
normalized,
|
|
1249
|
+
renyi,
|
|
1250
|
+
tsallis,
|
|
1251
|
+
interpretation: diversityInterpretation
|
|
1252
|
+
},
|
|
1253
|
+
distribution: {
|
|
1254
|
+
mean,
|
|
1255
|
+
median,
|
|
1256
|
+
stdDev,
|
|
1257
|
+
skewness,
|
|
1258
|
+
kurtosis,
|
|
1259
|
+
coefficientOfVariation: mean > 0 ? stdDev / mean : 0,
|
|
1260
|
+
iqr: q3 - q1,
|
|
1261
|
+
min: sorted[0] ?? 0,
|
|
1262
|
+
max: sorted[sorted.length - 1] ?? 0,
|
|
1263
|
+
range: (sorted[sorted.length - 1] ?? 0) - (sorted[0] ?? 0)
|
|
1264
|
+
},
|
|
1265
|
+
indices: {
|
|
1266
|
+
dominance,
|
|
1267
|
+
volatility,
|
|
1268
|
+
predictability,
|
|
1269
|
+
competitiveness,
|
|
1270
|
+
stability
|
|
1271
|
+
},
|
|
1272
|
+
predictions: {
|
|
1273
|
+
likelyWinner,
|
|
1274
|
+
winnerConfidence,
|
|
1275
|
+
estimatedTurnsToVictory,
|
|
1276
|
+
isEndgame,
|
|
1277
|
+
secondPlaceChallenger,
|
|
1278
|
+
comebackPossibility
|
|
1279
|
+
},
|
|
1280
|
+
anomalies,
|
|
1281
|
+
probability,
|
|
1282
|
+
topology: {
|
|
1283
|
+
totalRegions,
|
|
1284
|
+
averageRegionSize,
|
|
1285
|
+
territoryFragmentation,
|
|
1286
|
+
borderCellPercentage,
|
|
1287
|
+
avgCompactness
|
|
1288
|
+
},
|
|
1289
|
+
timeSeries: {
|
|
1290
|
+
overallTrend,
|
|
1291
|
+
changePoints,
|
|
1292
|
+
trendStrength,
|
|
1293
|
+
autocorrelation,
|
|
1294
|
+
seasonality
|
|
1295
|
+
},
|
|
1296
|
+
comparisons: {
|
|
1297
|
+
vs5TurnsAgo,
|
|
1298
|
+
vs10TurnsAgo,
|
|
1299
|
+
divergenceFromUniform,
|
|
1300
|
+
divergenceFromPrevious
|
|
1301
|
+
},
|
|
1302
|
+
insights
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Format snapshot as human-readable text
|
|
1308
|
+
*/
|
|
1309
|
+
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
|
+
|
|
1325
|
+
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('│')
|
|
1335
|
+
}
|
|
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
|
+
|
|
1355
|
+
if (snapshot.predictions.likelyWinner !== null) {
|
|
1356
|
+
lines.push(`│ Likely Winner: Player ${snapshot.predictions.likelyWinner}`)
|
|
1357
|
+
lines.push(`│ Confidence: ${(snapshot.predictions.winnerConfidence * 100).toFixed(1)}%`)
|
|
1358
|
+
if (snapshot.predictions.estimatedTurnsToVictory !== null) {
|
|
1359
|
+
lines.push(`│ Est. Victory In: ${snapshot.predictions.estimatedTurnsToVictory} turns`)
|
|
1360
|
+
}
|
|
1361
|
+
} else {
|
|
1362
|
+
lines.push('│ No clear winner predicted yet')
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (snapshot.predictions.isEndgame) {
|
|
1366
|
+
lines.push('│ ⚠️ ENDGAME DETECTED')
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
lines.push(`│ Comeback Chance: ${(snapshot.predictions.comebackPossibility * 100).toFixed(0)}%`)
|
|
1370
|
+
lines.push('└─────────────────────────────────────────────────────────┘')
|
|
1371
|
+
lines.push('')
|
|
1372
|
+
|
|
1373
|
+
if (snapshot.insights.length > 0) {
|
|
1374
|
+
lines.push('┌─────────────────────────────────────────────────────────┐')
|
|
1375
|
+
lines.push('│ INSIGHTS │')
|
|
1376
|
+
lines.push('├─────────────────────────────────────────────────────────┤')
|
|
1377
|
+
for (const insight of snapshot.insights) {
|
|
1378
|
+
lines.push(`│ ${insight}`)
|
|
1379
|
+
}
|
|
1380
|
+
lines.push('└─────────────────────────────────────────────────────────┘')
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return lines.join('\n')
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
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) + ']'
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Export snapshot as JSON (removes functions)
|
|
1394
|
+
*/
|
|
1395
|
+
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)
|
|
1402
|
+
}
|