@rine-kit/pubg-domain 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -2
  3. package/dist/config/default-team.json +27 -0
  4. package/dist/src/config/chicken-index.d.ts +9 -0
  5. package/dist/src/config/chicken-index.js +37 -0
  6. package/dist/src/config/chicken-index.js.map +1 -0
  7. package/dist/src/config/team.d.ts +22 -0
  8. package/dist/src/config/team.js +69 -0
  9. package/dist/src/config/team.js.map +1 -0
  10. package/dist/src/context/context-store.d.ts +40 -0
  11. package/dist/src/context/context-store.js +119 -0
  12. package/dist/src/context/context-store.js.map +1 -0
  13. package/dist/src/context/team-context.d.ts +13 -0
  14. package/dist/src/context/team-context.js +16 -0
  15. package/dist/src/context/team-context.js.map +1 -0
  16. package/dist/src/core.d.ts +23 -0
  17. package/dist/src/core.js +24 -0
  18. package/dist/src/core.js.map +1 -0
  19. package/dist/src/data/model.d.ts +123 -0
  20. package/dist/src/data/model.js +100 -0
  21. package/dist/src/data/model.js.map +1 -0
  22. package/dist/src/data/pubg-api-client.d.ts +59 -0
  23. package/dist/src/data/pubg-api-client.js +441 -0
  24. package/dist/src/data/pubg-api-client.js.map +1 -0
  25. package/dist/src/engine/query-engine.d.ts +20 -0
  26. package/dist/src/engine/query-engine.js +765 -0
  27. package/dist/src/engine/query-engine.js.map +1 -0
  28. package/dist/src/index.d.ts +27 -0
  29. package/dist/src/index.js +28 -0
  30. package/dist/src/index.js.map +1 -0
  31. package/dist/src/migration/importer.d.ts +35 -0
  32. package/dist/src/migration/importer.js +229 -0
  33. package/dist/src/migration/importer.js.map +1 -0
  34. package/dist/src/review/combat-scope.d.ts +11 -0
  35. package/dist/src/review/combat-scope.js +101 -0
  36. package/dist/src/review/combat-scope.js.map +1 -0
  37. package/dist/src/review/event-combination-engine.d.ts +17 -0
  38. package/dist/src/review/event-combination-engine.js +534 -0
  39. package/dist/src/review/event-combination-engine.js.map +1 -0
  40. package/dist/src/review/fight-detector.d.ts +4 -0
  41. package/dist/src/review/fight-detector.js +138 -0
  42. package/dist/src/review/fight-detector.js.map +1 -0
  43. package/dist/src/review/fight-integrity-validator.d.ts +13 -0
  44. package/dist/src/review/fight-integrity-validator.js +134 -0
  45. package/dist/src/review/fight-integrity-validator.js.map +1 -0
  46. package/dist/src/review/fun-candidate-generator.d.ts +9 -0
  47. package/dist/src/review/fun-candidate-generator.js +267 -0
  48. package/dist/src/review/fun-candidate-generator.js.map +1 -0
  49. package/dist/src/review/fun-event-generator.d.ts +8 -0
  50. package/dist/src/review/fun-event-generator.js +740 -0
  51. package/dist/src/review/fun-event-generator.js.map +1 -0
  52. package/dist/src/review/fun-intelligence.d.ts +13 -0
  53. package/dist/src/review/fun-intelligence.js +20 -0
  54. package/dist/src/review/fun-intelligence.js.map +1 -0
  55. package/dist/src/review/fun-ranker.d.ts +13 -0
  56. package/dist/src/review/fun-ranker.js +68 -0
  57. package/dist/src/review/fun-ranker.js.map +1 -0
  58. package/dist/src/review/heavy-weapon-intelligence.d.ts +5 -0
  59. package/dist/src/review/heavy-weapon-intelligence.js +215 -0
  60. package/dist/src/review/heavy-weapon-intelligence.js.map +1 -0
  61. package/dist/src/review/key-operation-detector.d.ts +3 -0
  62. package/dist/src/review/key-operation-detector.js +126 -0
  63. package/dist/src/review/key-operation-detector.js.map +1 -0
  64. package/dist/src/review/match-selector.d.ts +18 -0
  65. package/dist/src/review/match-selector.js +202 -0
  66. package/dist/src/review/match-selector.js.map +1 -0
  67. package/dist/src/review/review-analyzer.d.ts +2 -0
  68. package/dist/src/review/review-analyzer.js +652 -0
  69. package/dist/src/review/review-analyzer.js.map +1 -0
  70. package/dist/src/review/review-facts.d.ts +17 -0
  71. package/dist/src/review/review-facts.js +560 -0
  72. package/dist/src/review/review-facts.js.map +1 -0
  73. package/dist/src/review/review-share.d.ts +48 -0
  74. package/dist/src/review/review-share.js +37 -0
  75. package/dist/src/review/review-share.js.map +1 -0
  76. package/dist/src/review/selection-store.d.ts +25 -0
  77. package/dist/src/review/selection-store.js +80 -0
  78. package/dist/src/review/selection-store.js.map +1 -0
  79. package/dist/src/review/session-review.d.ts +72 -0
  80. package/dist/src/review/session-review.js +492 -0
  81. package/dist/src/review/session-review.js.map +1 -0
  82. package/dist/src/review/special-events.d.ts +3 -0
  83. package/dist/src/review/special-events.js +258 -0
  84. package/dist/src/review/special-events.js.map +1 -0
  85. package/dist/src/review/subgraph.d.ts +26 -0
  86. package/dist/src/review/subgraph.js +146 -0
  87. package/dist/src/review/subgraph.js.map +1 -0
  88. package/dist/src/review/supplemental-intelligence.d.ts +15 -0
  89. package/dist/src/review/supplemental-intelligence.js +431 -0
  90. package/dist/src/review/supplemental-intelligence.js.map +1 -0
  91. package/dist/src/review/team-damage.d.ts +8 -0
  92. package/dist/src/review/team-damage.js +180 -0
  93. package/dist/src/review/team-damage.js.map +1 -0
  94. package/dist/src/review/telemetry-events.d.ts +72 -0
  95. package/dist/src/review/telemetry-events.js +573 -0
  96. package/dist/src/review/telemetry-events.js.map +1 -0
  97. package/dist/src/review/telemetry.d.ts +86 -0
  98. package/dist/src/review/telemetry.js +266 -0
  99. package/dist/src/review/telemetry.js.map +1 -0
  100. package/dist/src/review/types.d.ts +501 -0
  101. package/dist/src/review/types.js +2 -0
  102. package/dist/src/review/types.js.map +1 -0
  103. package/dist/src/review/vehicle-intelligence.d.ts +3 -0
  104. package/dist/src/review/vehicle-intelligence.js +148 -0
  105. package/dist/src/review/vehicle-intelligence.js.map +1 -0
  106. package/dist/src/schema/query.d.ts +876 -0
  107. package/dist/src/schema/query.js +185 -0
  108. package/dist/src/schema/query.js.map +1 -0
  109. package/dist/src/schema/status.d.ts +66 -0
  110. package/dist/src/schema/status.js +49 -0
  111. package/dist/src/schema/status.js.map +1 -0
  112. package/dist/src/service/pubg-domain-service.d.ts +192 -0
  113. package/dist/src/service/pubg-domain-service.js +1598 -0
  114. package/dist/src/service/pubg-domain-service.js.map +1 -0
  115. package/dist/src/storage/repository-port.d.ts +84 -0
  116. package/dist/src/storage/repository-port.js +2 -0
  117. package/dist/src/storage/repository-port.js.map +1 -0
  118. package/dist/src/storage/sqlite-repository-adapter.d.ts +33 -0
  119. package/dist/src/storage/sqlite-repository-adapter.js +66 -0
  120. package/dist/src/storage/sqlite-repository-adapter.js.map +1 -0
  121. package/dist/src/storage/sqlite-repository.d.ts +73 -0
  122. package/dist/src/storage/sqlite-repository.js +652 -0
  123. package/dist/src/storage/sqlite-repository.js.map +1 -0
  124. package/dist/src/time/selector-resolver.d.ts +34 -0
  125. package/dist/src/time/selector-resolver.js +364 -0
  126. package/dist/src/time/selector-resolver.js.map +1 -0
  127. package/package.json +42 -3
@@ -0,0 +1,1598 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { parseTeamContext } from '../context/team-context.js';
3
+ import { CanonicalQuerySchema } from '../schema/query.js';
4
+ import { DeterministicQueryEngine, resultSetFromResult } from '../engine/query-engine.js';
5
+ import { PubgApiError } from '../data/pubg-api-client.js';
6
+ import { TelemetryWorker, PubgApiTelemetryDownloader, } from '../review/telemetry.js';
7
+ import { emptyMatchReviewFacts, scopeMatchReviewFacts, selectMatchReviewFactCategories, } from '../review/review-facts.js';
8
+ import { analyzeMatchReview } from '../review/review-analyzer.js';
9
+ import { BUSINESS_DAY_START, DEFAULT_TIMEZONE, localDateLabel, resolveSelector, } from '../time/selector-resolver.js';
10
+ export const PUBGMETRIC_VERSION = 'pubg-metrics-v1';
11
+ export const PUBG_QUERY_VERSION = 'pubg-query-v1';
12
+ export const PUBG_RESULT_SET_TTL_MS = 30 * 60 * 1000;
13
+ export const PUBG_REVIEW_SEARCH_MAX_AGE_MS = 5 * 60 * 1000;
14
+ function sourceRangeFromUnknown(selector) {
15
+ if (!selector ||
16
+ typeof selector !== 'object' ||
17
+ selector.type !== 'time_range')
18
+ return null;
19
+ const value = selector;
20
+ if (typeof value.start !== 'string' ||
21
+ typeof value.end !== 'string' ||
22
+ typeof value.timezone !== 'string' ||
23
+ typeof value.businessDayStart !== 'string')
24
+ return null;
25
+ return {
26
+ from: value.start,
27
+ to: value.end,
28
+ timezone: value.timezone,
29
+ businessDayStart: value.businessDayStart,
30
+ };
31
+ }
32
+ function sourceRangeFromSelector(selector) {
33
+ return sourceRangeFromUnknown(selector);
34
+ }
35
+ function asFiniteDate(value) {
36
+ const parsed = Date.parse(value);
37
+ return Number.isFinite(parsed) ? parsed : null;
38
+ }
39
+ function sourceForLocal(records, now, state, store) {
40
+ if (state)
41
+ return { ...state.source, localMatchCount: records.length };
42
+ return {
43
+ store,
44
+ syncInvoked: false,
45
+ playerApiCalls: 0,
46
+ matchApiCalls: 0,
47
+ localMatchCount: records.length,
48
+ };
49
+ }
50
+ function coverageForLocal(records, now, state) {
51
+ if (state) {
52
+ const coverage = {
53
+ ...state.coverage,
54
+ checkedAt: state.checkedAt,
55
+ availableMatchCount: records.length,
56
+ };
57
+ return coverage.status === 'SOURCE_UNAVAILABLE' && records.length > 0
58
+ ? staleCoverage(coverage, records, now)
59
+ : coverage;
60
+ }
61
+ const timestamps = records
62
+ .map((record) => record.timestamp)
63
+ .filter((value) => Number.isFinite(value));
64
+ return {
65
+ status: 'OK',
66
+ complete: true,
67
+ coverageStart: timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : null,
68
+ coverageEnd: now.toISOString(),
69
+ checkedAt: now.toISOString(),
70
+ failedMatchIds: [],
71
+ sourceUnavailable: false,
72
+ freshness: 'unknown',
73
+ localComplete: true,
74
+ queryCovered: true,
75
+ requiredMatchCount: records.length,
76
+ availableMatchCount: records.length,
77
+ };
78
+ }
79
+ function staleCoverage(coverage, records, now, failedMatchIds = []) {
80
+ const timestamps = records
81
+ .map((record) => record.timestamp)
82
+ .filter((value) => Number.isFinite(value));
83
+ const failures = [...new Set([...coverage.failedMatchIds, ...failedMatchIds])];
84
+ return {
85
+ ...coverage,
86
+ status: 'STALE',
87
+ complete: false,
88
+ coverageStart: coverage.coverageStart ??
89
+ (timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : null),
90
+ coverageEnd: coverage.coverageEnd ??
91
+ (timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null),
92
+ checkedAt: now.toISOString(),
93
+ failedMatchIds: failures,
94
+ sourceUnavailable: true,
95
+ freshness: 'stale',
96
+ localComplete: coverage.localComplete ?? false,
97
+ queryCovered: coverage.queryCovered ?? false,
98
+ requiredMatchCount: Math.max(coverage.requiredMatchCount ?? 0, records.length + failures.length),
99
+ availableMatchCount: records.length,
100
+ };
101
+ }
102
+ function coverageWithRecords(coverage, records, now) {
103
+ const timestamps = records
104
+ .map((record) => record.timestamp)
105
+ .filter((value) => Number.isFinite(value));
106
+ return {
107
+ ...coverage,
108
+ coverageStart: timestamps.length
109
+ ? new Date(Math.min(...timestamps)).toISOString()
110
+ : (coverage.coverageStart ?? null),
111
+ coverageEnd: now.toISOString(),
112
+ checkedAt: now.toISOString(),
113
+ requiredMatchCount: records.length + coverage.failedMatchIds.length,
114
+ availableMatchCount: records.length,
115
+ };
116
+ }
117
+ function uniqueStrings(values) {
118
+ return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
119
+ }
120
+ function searchOrder(input) {
121
+ // A bounded period is presented as a play-by-play review, so its implicit
122
+ // order is chronological. Explicit recentN keeps the latest-match contract
123
+ // newest-first; callers can still override the order explicitly.
124
+ if (input.sort)
125
+ return input.sort;
126
+ if (input.selector && input.recentN === undefined)
127
+ return 'asc';
128
+ return 'desc';
129
+ }
130
+ async function mapWithConcurrency(items, concurrency, task) {
131
+ if (!items.length)
132
+ return [];
133
+ const results = new Array(items.length);
134
+ let nextIndex = 0;
135
+ const worker = async () => {
136
+ while (true) {
137
+ const index = nextIndex;
138
+ nextIndex += 1;
139
+ if (index >= items.length)
140
+ return;
141
+ results[index] = await task(items[index]);
142
+ }
143
+ };
144
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
145
+ return results;
146
+ }
147
+ const TEAM_DAMAGE_SOURCES = ['MELEE', 'GUN', 'EXPLOSIVE', 'VEHICLE'];
148
+ function roundDamage(value) {
149
+ return Math.round(value * 100) / 100;
150
+ }
151
+ function teamDamageFactMatches(fact, actorPlayerId, victimPlayerId, source, meleeKind) {
152
+ return ((!actorPlayerId || fact.actorPlayerId === actorPlayerId) &&
153
+ (!victimPlayerId || fact.victimPlayerId === victimPlayerId) &&
154
+ (!source || fact.source === source) &&
155
+ (!meleeKind || fact.meleeKind === meleeKind));
156
+ }
157
+ function retryAt(now, attemptCount) {
158
+ const delayMs = Math.min(24 * 60 * 60 * 1000, 60 * 60 * 1000 * 2 ** Math.max(0, attemptCount - 1));
159
+ return new Date(now.getTime() + delayMs).toISOString();
160
+ }
161
+ function previousLocalDate(now, timezone) {
162
+ return localDateLabel(now.getTime() - 24 * 60 * 60 * 1000, timezone);
163
+ }
164
+ function summarizePrefetchRuns(reportDate, timezone, runs) {
165
+ const sum = (selector) => runs.reduce((total, run) => total + selector(run), 0);
166
+ return {
167
+ reportDate,
168
+ timezone,
169
+ runCount: runs.length,
170
+ discoveredMatchCount: sum((run) => run.discoveredMatchCount),
171
+ newMatchCount: sum((run) => run.newMatchCount),
172
+ candidateMatchCount: sum((run) => run.candidateMatchCount),
173
+ fetchedCount: sum((run) => run.fetchedCount),
174
+ cacheHitCount: sum((run) => run.cacheHitCount),
175
+ unavailableCount: sum((run) => run.unavailableCount),
176
+ pendingCount: sum((run) => run.pendingCount),
177
+ failedMatchIds: [...new Set(runs.flatMap((run) => run.failedMatchIds))],
178
+ lastRunAt: runs.length ? runs[runs.length - 1].finishedAt : null,
179
+ };
180
+ }
181
+ function playerIdsFromSubject(input, team) {
182
+ const ids = uniqueStrings(input.playerIds);
183
+ const names = uniqueStrings(input.playerNames);
184
+ if (!ids.length && !names.length)
185
+ return { ids: team.players.map((player) => player.id), names: [] };
186
+ return { ids, names };
187
+ }
188
+ function lowerStatus(status) {
189
+ if (status === 'NO_MATCHES')
190
+ return 'no_matches';
191
+ if (status === 'OK')
192
+ return 'ok';
193
+ if (status === 'PARTIAL' ||
194
+ status === 'COVERAGE_GAP' ||
195
+ status === 'STALE' ||
196
+ status === 'REVIEW_PARTIAL')
197
+ return 'partial';
198
+ return 'error';
199
+ }
200
+ function errorFor(status, diagnostics, source) {
201
+ if (status === 'OK' ||
202
+ status === 'NO_MATCHES' ||
203
+ status === 'PARTIAL' ||
204
+ status === 'COVERAGE_GAP' ||
205
+ status === 'STALE' ||
206
+ status === 'REVIEW_PARTIAL')
207
+ return undefined;
208
+ const code = typeof diagnostics.errorCode === 'string'
209
+ ? diagnostics.errorCode
210
+ : status === 'SOURCE_UNAVAILABLE'
211
+ ? 'source_unavailable'
212
+ : status.toLowerCase();
213
+ return {
214
+ code,
215
+ retryable: status === 'SOURCE_UNAVAILABLE',
216
+ reason: typeof source.error === 'string'
217
+ ? source.error
218
+ : String(diagnostics.errors?.[0] ?? status),
219
+ };
220
+ }
221
+ function evidenceFor(result) {
222
+ return {
223
+ ...result.evidence,
224
+ calculation: result.evidence.calculation || PUBG_QUERY_VERSION,
225
+ };
226
+ }
227
+ function sanitize(value) {
228
+ if (typeof value === 'number')
229
+ return Number.isFinite(value) ? value : null;
230
+ if (Array.isArray(value))
231
+ return value.map(sanitize);
232
+ if (value && typeof value === 'object') {
233
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)]));
234
+ }
235
+ return value;
236
+ }
237
+ function selectorToCanonical(selector, timezone, businessDayStart) {
238
+ if (selector.type === 'time_range') {
239
+ return {
240
+ type: 'time_range',
241
+ start: selector.from,
242
+ end: selector.to,
243
+ timezone: selector.timezone ?? timezone,
244
+ businessDayStart: selector.businessDayStart ?? businessDayStart,
245
+ };
246
+ }
247
+ if (selector.type === 'relative_period') {
248
+ return {
249
+ type: 'relative_period',
250
+ value: selector.value,
251
+ ...(selector.label ? { label: selector.label } : {}),
252
+ };
253
+ }
254
+ if (selector.type === 'recent_days') {
255
+ return {
256
+ type: 'recent_days',
257
+ count: Math.min(Math.max(Math.trunc(selector.count), 1), 366),
258
+ ...(selector.label ? { label: selector.label } : {}),
259
+ };
260
+ }
261
+ if (selector.type === 'last_n_matches') {
262
+ return {
263
+ type: 'last_n_matches',
264
+ count: Math.min(Math.max(Math.trunc(selector.count), 1), 100),
265
+ offset: Math.max(0, Math.trunc(selector.offset ?? 0)),
266
+ };
267
+ }
268
+ return { type: 'result_set', resultSetId: selector.resultSetId };
269
+ }
270
+ function queryForStats(input, subjectIds, timezone, businessDayStart) {
271
+ const operation = input.operation ?? 'report';
272
+ const groupBy = input.groupBy ?? 'player';
273
+ const metrics = uniqueStrings(input.metrics);
274
+ const orderBy = input.orderBy ?? { metric: metrics[0] ?? 'kills', direction: 'desc' };
275
+ return CanonicalQuerySchema.parse({
276
+ version: 3,
277
+ queryId: 'pubg-' + randomUUID(),
278
+ domain: 'pubg',
279
+ subject: { type: subjectIds.length === 1 ? 'player' : 'players', ids: subjectIds },
280
+ operation,
281
+ selector: selectorToCanonical(input.selector, timezone, businessDayStart),
282
+ matchSelector: null,
283
+ segments: [],
284
+ groupBy,
285
+ metrics: metrics.length ? metrics : ['kills'],
286
+ filters: {},
287
+ orderBy,
288
+ limit: input.limit === undefined ? null : Math.min(Math.max(Math.trunc(input.limit), 1), 100),
289
+ reference: {
290
+ sessionId: input.sessionId,
291
+ selectorExplicit: true,
292
+ subjectExplicit: Boolean(input.playerIds?.length || input.playerNames?.length),
293
+ useResultSet: input.selector.type === 'result_set',
294
+ resultSetId: input.selector.type === 'result_set' ? input.selector.resultSetId : undefined,
295
+ inheritedFromContext: false,
296
+ planner: 'provided',
297
+ },
298
+ presentation: { compact: true },
299
+ });
300
+ }
301
+ function queryForCompare(input, subjectIds, timezone, businessDayStart) {
302
+ const metrics = uniqueStrings(input.metrics);
303
+ return CanonicalQuerySchema.parse({
304
+ version: 3,
305
+ queryId: 'pubg-' + randomUUID(),
306
+ domain: 'pubg',
307
+ subject: { type: subjectIds.length === 1 ? 'player' : 'players', ids: subjectIds },
308
+ operation: 'compare',
309
+ selector: selectorToCanonical(input.segments[0]?.selector ?? { type: 'last_n_matches', count: 1 }, timezone, businessDayStart),
310
+ matchSelector: null,
311
+ segments: input.segments.slice(0, 2).map((segment) => ({
312
+ label: segment.label,
313
+ selector: selectorToCanonical(segment.selector, timezone, businessDayStart),
314
+ })),
315
+ groupBy: input.groupBy ?? 'player',
316
+ metrics: metrics.length ? metrics : ['kills'],
317
+ filters: {},
318
+ orderBy: input.orderBy ?? { metric: metrics[0] ?? 'kills', direction: 'desc' },
319
+ limit: input.limit === undefined ? null : Math.min(Math.max(Math.trunc(input.limit), 1), 100),
320
+ reference: {
321
+ sessionId: input.sessionId,
322
+ selectorExplicit: true,
323
+ subjectExplicit: Boolean(input.playerIds?.length || input.playerNames?.length),
324
+ inheritedFromContext: false,
325
+ planner: 'provided',
326
+ },
327
+ presentation: { compact: true },
328
+ });
329
+ }
330
+ function resultEnvelope(result, query, resultSetId) {
331
+ const diagnostics = result.diagnostics ?? {};
332
+ const resolvedSelector = diagnostics.resolvedSelector;
333
+ const resolvedSegments = Array.isArray(diagnostics.resolvedSegments)
334
+ ? diagnostics.resolvedSegments
335
+ : [];
336
+ const resolvedRange = sourceRangeFromUnknown(resolvedSelector);
337
+ const resolvedQuery = {
338
+ ...sanitize(query),
339
+ ...(resolvedSelector && typeof resolvedSelector === 'object'
340
+ ? { resolvedSelector: sanitize(resolvedSelector) }
341
+ : {}),
342
+ ...(resolvedSegments.length ? { resolvedSegments: sanitize(resolvedSegments) } : {}),
343
+ ...(resolvedRange ? { sourceRange: resolvedRange } : {}),
344
+ };
345
+ const envelope = {
346
+ status: lowerStatus(result.status),
347
+ data: sanitize(result.data),
348
+ coverage: sanitize(result.coverage),
349
+ asOf: result.coverage.checkedAt ?? new Date().toISOString(),
350
+ metricVersion: PUBGMETRIC_VERSION,
351
+ queryResolved: resolvedQuery,
352
+ evidenceRefs: sanitize(evidenceFor(result)),
353
+ ...(resultSetId ? { resultSetId } : {}),
354
+ };
355
+ const error = errorFor(result.status, result.diagnostics, result.source);
356
+ if (error)
357
+ envelope.error = error;
358
+ return envelope;
359
+ }
360
+ export class PubgDomainService {
361
+ teamContext;
362
+ repository;
363
+ apiClient;
364
+ telemetryWorker;
365
+ timezone;
366
+ businessDayStart;
367
+ maxMatches;
368
+ now;
369
+ freshnessMs;
370
+ queryEngine;
371
+ prefetchInFlight;
372
+ constructor(options) {
373
+ this.teamContext = parseTeamContext(options.teamContext);
374
+ if (options.repository.scope.tenantId !== this.teamContext.tenantId ||
375
+ options.repository.scope.teamId !== this.teamContext.teamId) {
376
+ throw new Error('repository_scope_mismatch');
377
+ }
378
+ this.repository = options.repository;
379
+ const apiClient = options.apiClient;
380
+ this.apiClient = apiClient;
381
+ this.timezone = options.timezone ?? DEFAULT_TIMEZONE;
382
+ this.businessDayStart = options.businessDayStart ?? BUSINESS_DAY_START;
383
+ this.maxMatches = Math.min(Math.max(1, Math.trunc(options.maxMatches ?? 500)), 1000);
384
+ this.now = options.now ?? (() => new Date());
385
+ this.freshnessMs = options.freshnessMs ?? 5 * 60 * 1000;
386
+ this.telemetryWorker =
387
+ options.telemetryWorker ??
388
+ new TelemetryWorker({
389
+ team: this.team,
390
+ store: this.repository,
391
+ ...(apiClient
392
+ ? {
393
+ downloader: new PubgApiTelemetryDownloader({
394
+ apiKey: apiClient.apiKey,
395
+ baseUrl: apiClient.baseUrl,
396
+ ...(options.rawTelemetryArchive ? { archive: options.rawTelemetryArchive } : {}),
397
+ }),
398
+ }
399
+ : {}),
400
+ });
401
+ this.queryEngine = new DeterministicQueryEngine({
402
+ teamContext: this.teamContext,
403
+ timezone: this.timezone,
404
+ businessDayStart: this.businessDayStart,
405
+ now: this.now(),
406
+ });
407
+ }
408
+ get team() {
409
+ return this.teamContext.team;
410
+ }
411
+ async refresh(refresh = true, maxMatches = 500, signal) {
412
+ const now = this.now();
413
+ const existing = await this.repository.listMatches();
414
+ const state = await this.repository.getSyncState('team:' + this.team.id);
415
+ const stateChecked = state ? Date.parse(state.checkedAt) : Number.NaN;
416
+ if (!refresh &&
417
+ state &&
418
+ Number.isFinite(stateChecked) &&
419
+ now.getTime() - stateChecked < this.freshnessMs) {
420
+ return {
421
+ records: existing,
422
+ coverage: coverageForLocal(existing, now, state),
423
+ source: sourceForLocal(existing, now, state, this.repository.store),
424
+ diagnostics: { refreshed: false, reason: 'fresh_cache' },
425
+ newMatchIds: [],
426
+ };
427
+ }
428
+ if (!this.apiClient)
429
+ return {
430
+ records: existing,
431
+ coverage: coverageForLocal(existing, now, state),
432
+ source: sourceForLocal(existing, now, state, this.repository.store),
433
+ diagnostics: { refreshed: false, reason: 'api_not_configured' },
434
+ newMatchIds: [],
435
+ };
436
+ const knownIds = state?.coverage.status !== 'SOURCE_UNAVAILABLE' && Array.isArray(state?.discoveredMatchIds)
437
+ ? new Set(state.discoveredMatchIds)
438
+ : new Set();
439
+ const synced = await this.apiClient.syncTeam(this.team, {
440
+ maxMatches,
441
+ // Match rows are shared public cache entries in PostgreSQL. Only the
442
+ // team-scoped sync ledger proves this roster has already expanded each
443
+ // discovered match into its own authorized player subset.
444
+ knownMatchIds: existing
445
+ .map((record) => record.matchId)
446
+ .filter((matchId) => knownIds.has(matchId)),
447
+ ...(signal ? { signal } : {}),
448
+ });
449
+ if (synced.records.length) {
450
+ await this.repository.upsertMatches(synced.records, {
451
+ source: 'pubg-api',
452
+ fetchedAt: now.toISOString(),
453
+ checkedPlayerIds: this.team.players.map((player) => player.id),
454
+ });
455
+ }
456
+ const merged = await this.repository.listMatches();
457
+ const coverage = synced.coverage.status === 'SOURCE_UNAVAILABLE' && merged.length > 0
458
+ ? staleCoverage(state?.coverage ?? synced.coverage, merged, now, synced.coverage.failedMatchIds)
459
+ : coverageWithRecords(synced.coverage, merged, now);
460
+ const stateValue = {
461
+ key: 'team:' + this.team.id,
462
+ checkedAt: now.toISOString(),
463
+ coverage,
464
+ source: { ...synced.source, localMatchCount: merged.length },
465
+ discoveredMatchIds: synced.discoveredMatchIds,
466
+ failedMatchIds: coverage.failedMatchIds,
467
+ ...(synced.source.error ? { error: synced.source.error } : {}),
468
+ };
469
+ await this.repository.setSyncState(stateValue);
470
+ return {
471
+ records: merged,
472
+ coverage: stateValue.coverage,
473
+ source: stateValue.source,
474
+ diagnostics: synced.diagnostics ?? {},
475
+ newMatchIds: synced.records.map((record) => record.matchId),
476
+ };
477
+ }
478
+ resolveSubject(input) {
479
+ const requested = playerIdsFromSubject(input, this.team);
480
+ const aliasMap = new Map();
481
+ for (const player of this.team.players) {
482
+ aliasMap.set(player.id.toLowerCase(), player);
483
+ aliasMap.set(player.name.toLowerCase(), player);
484
+ for (const alias of player.aliases)
485
+ aliasMap.set(alias.toLowerCase(), player);
486
+ }
487
+ const players = [
488
+ ...requested.ids
489
+ .map((id) => this.team.players.find((player) => player.id === id))
490
+ .filter((player) => Boolean(player)),
491
+ ];
492
+ for (const name of requested.names) {
493
+ const player = aliasMap.get(name.toLowerCase());
494
+ if (!player)
495
+ return {
496
+ ids: [],
497
+ players: [],
498
+ error: { code: 'unknown_player', retryable: false, reason: name },
499
+ };
500
+ if (!players.some((item) => item.id === player.id))
501
+ players.push(player);
502
+ }
503
+ const unknownIds = requested.ids.filter((id) => !this.team.players.some((player) => player.id === id));
504
+ if (unknownIds.length)
505
+ return {
506
+ ids: [],
507
+ players: [],
508
+ error: { code: 'unknown_player', retryable: false, reason: unknownIds.join(',') },
509
+ };
510
+ return { ids: players.map((player) => player.id), players };
511
+ }
512
+ async resolvePlayers(input) {
513
+ const requested = playerIdsFromSubject(input, this.team);
514
+ const subject = this.resolveSubject(input);
515
+ if (subject.error && input.playerNames?.length && this.apiClient) {
516
+ try {
517
+ const entries = await this.apiClient.discoverPlayers(this.team.platform, [], input.playerNames, input.signal);
518
+ const resolved = entries
519
+ .map((entry) => ({
520
+ accountId: String(entry.id ?? ''),
521
+ apiName: String(entry.attributes?.name ?? ''),
522
+ }))
523
+ .filter((item) => item.accountId);
524
+ const exact = resolved.filter((item) => input.playerNames?.some((name) => item.apiName.toLowerCase() === name.toLowerCase()));
525
+ return {
526
+ status: exact.length === 1 ? 'ok' : exact.length > 1 ? 'error' : 'no_matches',
527
+ data: { requestedNames: input.playerNames, matches: exact, configured: false },
528
+ coverage: coverageForLocal([], this.now(), null),
529
+ asOf: this.now().toISOString(),
530
+ metricVersion: PUBGMETRIC_VERSION,
531
+ queryResolved: { tool: 'pubg_resolve_players', platform: this.team.platform },
532
+ evidenceRefs: {
533
+ matchIds: [],
534
+ playerIds: exact.map((item) => item.accountId),
535
+ fields: ['accountId', 'apiName'],
536
+ calculation: 'configured_alias_or_official_player_lookup',
537
+ },
538
+ ...(exact.length > 1
539
+ ? {
540
+ error: {
541
+ code: 'ambiguous_player',
542
+ retryable: false,
543
+ reason: 'multiple official players matched',
544
+ },
545
+ }
546
+ : {}),
547
+ };
548
+ }
549
+ catch (error) {
550
+ return this.errorEnvelope('player_lookup_failed', error instanceof PubgApiError ? error.retryable : true, error instanceof Error ? error.message : 'player lookup failed');
551
+ }
552
+ }
553
+ if (subject.error)
554
+ return this.errorEnvelope(subject.error.code, subject.error.retryable, subject.error.reason);
555
+ return {
556
+ status: 'ok',
557
+ data: {
558
+ platform: this.team.platform,
559
+ teamId: this.team.id,
560
+ teamLabel: this.team.label,
561
+ players: subject.players.map((player) => ({
562
+ accountId: player.id,
563
+ name: player.name,
564
+ aliases: player.aliases,
565
+ })),
566
+ },
567
+ coverage: coverageForLocal([], this.now(), null),
568
+ asOf: this.now().toISOString(),
569
+ metricVersion: PUBGMETRIC_VERSION,
570
+ queryResolved: { tool: 'pubg_resolve_players', requested: requested },
571
+ evidenceRefs: {
572
+ matchIds: [],
573
+ playerIds: subject.ids,
574
+ fields: ['accountId', 'name', 'aliases'],
575
+ calculation: 'configured_team_aliases',
576
+ },
577
+ };
578
+ }
579
+ errorEnvelope(code, retryable, reason) {
580
+ return {
581
+ status: 'error',
582
+ data: {},
583
+ coverage: coverageForLocal([], this.now(), null),
584
+ asOf: this.now().toISOString(),
585
+ metricVersion: PUBGMETRIC_VERSION,
586
+ queryResolved: {},
587
+ evidenceRefs: { matchIds: [], playerIds: [], fields: [], calculation: PUBG_QUERY_VERSION },
588
+ error: { code, retryable, reason },
589
+ };
590
+ }
591
+ async queryStats(input) {
592
+ const subject = this.resolveSubject(input);
593
+ if (subject.error)
594
+ return this.errorEnvelope(subject.error.code, subject.error.retryable, subject.error.reason);
595
+ const source = await this.refresh(input.refresh !== false || input.selector.type === 'last_n_matches', this.maxMatches, input.signal);
596
+ let resultSetMatchIds;
597
+ if (input.selector.type === 'result_set') {
598
+ const resultSet = await this.repository.getResultSet(input.sessionId, input.selector.resultSetId, this.now());
599
+ if (!resultSet)
600
+ return this.errorEnvelope('result_set_not_found_or_session_mismatch', false, 'result set is missing, expired, or belongs to another session');
601
+ resultSetMatchIds = resultSet.matchIds;
602
+ }
603
+ const query = queryForStats(input, subject.ids, this.timezone, this.businessDayStart);
604
+ const result = this.queryEngine.execute(query, source.records, source.coverage, source.source, {
605
+ teamContext: this.teamContext,
606
+ now: this.now(),
607
+ ...(resultSetMatchIds ? { resultSetMatchIds } : {}),
608
+ });
609
+ const resultSet = resultSetFromResult(result, query, input.sessionId, PUBG_RESULT_SET_TTL_MS);
610
+ await this.repository.setResultSet(resultSet);
611
+ return resultEnvelope(result, query, resultSet.id);
612
+ }
613
+ async compareStats(input) {
614
+ const subject = this.resolveSubject(input);
615
+ if (subject.error)
616
+ return this.errorEnvelope(subject.error.code, subject.error.retryable, subject.error.reason);
617
+ const source = await this.refresh(input.refresh !== false, this.maxMatches, input.signal);
618
+ const query = queryForCompare(input, subject.ids, this.timezone, this.businessDayStart);
619
+ const result = this.queryEngine.execute(query, source.records, source.coverage, source.source, {
620
+ teamContext: this.teamContext,
621
+ now: this.now(),
622
+ });
623
+ const resultSet = resultSetFromResult(result, query, input.sessionId, PUBG_RESULT_SET_TTL_MS);
624
+ await this.repository.setResultSet(resultSet);
625
+ return resultEnvelope(result, query, resultSet.id);
626
+ }
627
+ async searchMatches(input) {
628
+ const subject = this.resolveSubject(input);
629
+ if (subject.error)
630
+ return this.errorEnvelope(subject.error.code, subject.error.retryable, subject.error.reason);
631
+ const forceFreshSearch = input.recentN !== undefined || input.selector !== undefined;
632
+ const source = await this.refresh(input.refresh !== false || forceFreshSearch, this.maxMatches, input.signal);
633
+ const now = this.now();
634
+ const selectorTimezone = input.selector?.type === 'time_range'
635
+ ? (input.selector.timezone ?? this.timezone)
636
+ : this.timezone;
637
+ const selectorBusinessDayStart = input.selector?.type === 'time_range'
638
+ ? (input.selector.businessDayStart ?? this.businessDayStart)
639
+ : this.businessDayStart;
640
+ const canonicalSearchSelector = input.selector
641
+ ? input.selector.type === 'time_range'
642
+ ? {
643
+ type: 'time_range',
644
+ start: input.selector.from,
645
+ end: input.selector.to,
646
+ timezone: input.selector.timezone ?? this.timezone,
647
+ businessDayStart: input.selector.businessDayStart ?? this.businessDayStart,
648
+ }
649
+ : input.selector
650
+ : null;
651
+ const resolvedSelector = canonicalSearchSelector
652
+ ? resolveSelector(canonicalSearchSelector, {
653
+ timezone: selectorTimezone,
654
+ businessDayStart: selectorBusinessDayStart,
655
+ now,
656
+ })
657
+ : null;
658
+ const from = resolvedSelector
659
+ ? Date.parse(resolvedSelector.start)
660
+ : input.from
661
+ ? asFiniteDate(input.from)
662
+ : 0;
663
+ const to = resolvedSelector
664
+ ? Date.parse(resolvedSelector.end)
665
+ : input.to
666
+ ? asFiniteDate(input.to)
667
+ : now.getTime();
668
+ if (from === null || to === null || from >= to)
669
+ return this.errorEnvelope('invalid_time_range', false, 'from/to must be valid and from < to');
670
+ const selected = source.records.filter((match) => match.timestamp >= from &&
671
+ match.timestamp < to &&
672
+ match.isCompetitive !== false &&
673
+ match.players.some((player) => subject.ids.includes(player.accountId)) &&
674
+ (!input.gameMode || match.gameMode.toLowerCase() === input.gameMode.toLowerCase()) &&
675
+ (!input.mapName || match.mapName.toLowerCase() === input.mapName.toLowerCase()));
676
+ const order = searchOrder(input);
677
+ const ordered = [...selected].sort((left, right) => order === 'desc'
678
+ ? right.timestamp - left.timestamp || right.matchId.localeCompare(left.matchId)
679
+ : left.timestamp - right.timestamp || left.matchId.localeCompare(right.matchId));
680
+ const pageSize = Math.min(Math.max(Math.trunc(input.pageSize ?? 20), 1), 50);
681
+ const page = Math.min(Math.max(Math.trunc(input.page ?? 0), 0), 100);
682
+ const recent = input.recentN
683
+ ? ordered.slice(0, Math.min(Math.max(Math.trunc(input.recentN), 1), 100))
684
+ : ordered.slice(page * pageSize, page * pageSize + pageSize);
685
+ const rows = recent.map((match) => {
686
+ const players = match.players.filter((player) => subject.ids.includes(player.accountId));
687
+ const ranks = players
688
+ .map((player) => player.rank)
689
+ .filter((rank) => rank !== null);
690
+ return {
691
+ matchId: match.matchId,
692
+ startedAt: match.createdAt,
693
+ timestamp: match.timestamp,
694
+ mapName: match.mapName,
695
+ gameMode: match.gameMode,
696
+ duration: match.duration,
697
+ patchVersion: match.patchVersion,
698
+ placement: ranks.length ? Math.min(...ranks) : null,
699
+ players: players.map((player) => ({
700
+ accountId: player.accountId,
701
+ name: player.playerName,
702
+ kills: player.kills,
703
+ assists: player.assists,
704
+ damage: player.damage,
705
+ dbnos: player.dbnos,
706
+ rank: player.rank,
707
+ })),
708
+ };
709
+ });
710
+ const query = {
711
+ tool: 'pubg_search_matches',
712
+ sessionId: input.sessionId,
713
+ selector: {
714
+ ...(input.selector ?? { type: 'time_range' }),
715
+ from: new Date(from).toISOString(),
716
+ to: new Date(to).toISOString(),
717
+ timezone: resolvedSelector?.timezone ?? input.timezone ?? this.timezone,
718
+ businessDayStart: resolvedSelector?.businessDayStart ?? selectorBusinessDayStart,
719
+ },
720
+ subject: subject.ids,
721
+ filters: { gameMode: input.gameMode ?? null, mapName: input.mapName ?? null },
722
+ order,
723
+ page,
724
+ pageSize,
725
+ refresh: {
726
+ requested: input.refresh !== false || forceFreshSearch,
727
+ forcedForRecent: input.recentN !== undefined,
728
+ forcedForSelector: input.selector !== undefined,
729
+ syncInvoked: source.source.syncInvoked,
730
+ playerApiCalls: source.source.playerApiCalls,
731
+ matchApiCalls: source.source.matchApiCalls,
732
+ newMatchCount: source.diagnostics.newMatchCount ?? 0,
733
+ cachedMatchCount: source.diagnostics.cachedMatchCount ?? 0,
734
+ cacheReason: source.diagnostics.reason ?? null,
735
+ },
736
+ };
737
+ const resultSetId = 'mrs_' + randomUUID();
738
+ const resultSet = {
739
+ id: resultSetId,
740
+ queryId: 'search-' + randomUUID(),
741
+ sessionId: input.sessionId,
742
+ resolvedQuery: queryForStats({
743
+ sessionId: input.sessionId,
744
+ selector: {
745
+ type: 'time_range',
746
+ from: new Date(from).toISOString(),
747
+ to: new Date(to).toISOString(),
748
+ },
749
+ metrics: ['matches'],
750
+ operation: 'list',
751
+ groupBy: 'match',
752
+ }, subject.ids, this.timezone, this.businessDayStart),
753
+ resolvedSelector: {
754
+ type: 'time_range',
755
+ start: new Date(from).toISOString(),
756
+ end: new Date(to).toISOString(),
757
+ timezone: resolvedSelector?.timezone ?? selectorTimezone,
758
+ businessDayStart: resolvedSelector?.businessDayStart ?? selectorBusinessDayStart,
759
+ },
760
+ playerIds: subject.ids,
761
+ matchIds: recent.map((match) => match.matchId),
762
+ rows: [],
763
+ aggregates: {
764
+ candidateCount: selected.length,
765
+ returnedCount: rows.length,
766
+ page,
767
+ pageSize,
768
+ order,
769
+ },
770
+ rankings: [],
771
+ coverage: source.coverage,
772
+ status: rows.length
773
+ ? source.coverage.status === 'OK'
774
+ ? 'OK'
775
+ : source.coverage.status
776
+ : source.coverage.complete
777
+ ? 'NO_MATCHES'
778
+ : source.coverage.status,
779
+ source: source.source,
780
+ createdAt: now.toISOString(),
781
+ expiresAt: new Date(now.getTime() + PUBG_RESULT_SET_TTL_MS).toISOString(),
782
+ };
783
+ await this.repository.setResultSet(resultSet);
784
+ const usableCachedCoverage = ['STALE', 'PARTIAL', 'COVERAGE_GAP'].includes(source.coverage.status);
785
+ const status = rows.length
786
+ ? source.coverage.status === 'OK'
787
+ ? 'ok'
788
+ : 'partial'
789
+ : usableCachedCoverage
790
+ ? 'partial'
791
+ : source.coverage.complete
792
+ ? 'no_matches'
793
+ : 'error';
794
+ const envelope = {
795
+ status,
796
+ data: {
797
+ matches: sanitize(rows),
798
+ total: selected.length,
799
+ page,
800
+ pageSize,
801
+ hasMore: (page + 1) * pageSize < selected.length,
802
+ },
803
+ coverage: source.coverage,
804
+ asOf: source.coverage.checkedAt ?? now.toISOString(),
805
+ metricVersion: PUBGMETRIC_VERSION,
806
+ queryResolved: query,
807
+ evidenceRefs: {
808
+ matchIds: recent.map((match) => match.matchId),
809
+ playerIds: subject.ids,
810
+ fields: [
811
+ 'matchId',
812
+ 'startedAt',
813
+ 'mapName',
814
+ 'gameMode',
815
+ 'placement',
816
+ 'players.kills',
817
+ 'players.damage',
818
+ ],
819
+ calculation: 'deterministic_match_search',
820
+ },
821
+ resultSetId,
822
+ };
823
+ if (source.coverage.status === 'SOURCE_UNAVAILABLE')
824
+ envelope.error = {
825
+ code: 'source_unavailable',
826
+ retryable: true,
827
+ reason: source.source.error ?? 'PUBG API unavailable',
828
+ };
829
+ return envelope;
830
+ }
831
+ async prefetchTelemetry(input = {}) {
832
+ if (this.prefetchInFlight)
833
+ return this.prefetchInFlight;
834
+ const pending = this.runTelemetryPrefetch(input);
835
+ this.prefetchInFlight = pending;
836
+ try {
837
+ return await pending;
838
+ }
839
+ finally {
840
+ if (this.prefetchInFlight === pending)
841
+ this.prefetchInFlight = undefined;
842
+ }
843
+ }
844
+ async runTelemetryPrefetch(input) {
845
+ const started = this.now();
846
+ const maxMatches = Math.min(Math.max(Math.trunc(input.maxMatches ?? this.maxMatches), 1), 1000);
847
+ const maxFetches = Math.min(Math.max(Math.trunc(input.maxFetches ?? 20), 1), 50);
848
+ const concurrency = Math.min(Math.max(Math.trunc(input.concurrency ?? 2), 1), 4);
849
+ const source = await this.refresh(true, maxMatches, input.signal);
850
+ const key = {
851
+ parserVersion: this.telemetryWorker.parserVersion,
852
+ featureVersion: this.telemetryWorker.featureVersion,
853
+ };
854
+ const retryCandidates = await this.repository.listDueTelemetryPrefetchAttempts(key.parserVersion, key.featureVersion, started, 1000);
855
+ const candidateIds = [
856
+ ...new Set([...source.newMatchIds, ...retryCandidates.map((attempt) => attempt.matchId)]),
857
+ ];
858
+ const candidateMatches = [];
859
+ for (const matchId of candidateIds) {
860
+ const match = await this.repository.getMatch(matchId);
861
+ if (match)
862
+ candidateMatches.push(match);
863
+ }
864
+ const selected = candidateMatches.slice(0, maxFetches);
865
+ const deferred = candidateMatches.slice(maxFetches);
866
+ for (const match of deferred) {
867
+ const previous = await this.repository.getTelemetryPrefetchAttempt({
868
+ ...key,
869
+ matchId: match.matchId,
870
+ });
871
+ await this.repository.setTelemetryPrefetchAttempt({
872
+ matchId: match.matchId,
873
+ ...key,
874
+ status: 'PENDING',
875
+ attemptCount: previous?.attemptCount ?? 0,
876
+ lastAttemptedAt: previous?.lastAttemptedAt ?? null,
877
+ nextRetryAt: started.toISOString(),
878
+ ...(previous?.error ? { error: previous.error } : {}),
879
+ updatedAt: started.toISOString(),
880
+ });
881
+ }
882
+ const outcomes = await mapWithConcurrency(selected, concurrency, async (match) => ({
883
+ matchId: match.matchId,
884
+ result: await this.telemetryWorker.ensure(match, 1, input.signal),
885
+ }));
886
+ let fetchedCount = 0;
887
+ let cacheHitCount = 0;
888
+ let unavailableCount = 0;
889
+ const unavailableIds = [];
890
+ for (const outcome of outcomes) {
891
+ const previous = await this.repository.getTelemetryPrefetchAttempt({
892
+ ...key,
893
+ matchId: outcome.matchId,
894
+ });
895
+ const result = outcome.result;
896
+ if (result.status === 'FETCHED')
897
+ fetchedCount += 1;
898
+ else if (result.status === 'HIT')
899
+ cacheHitCount += 1;
900
+ else {
901
+ unavailableCount += 1;
902
+ unavailableIds.push(outcome.matchId);
903
+ }
904
+ const attemptCount = result.status === 'UNAVAILABLE'
905
+ ? (previous?.attemptCount ?? 0) + 1
906
+ : (previous?.attemptCount ?? 0);
907
+ await this.repository.setTelemetryPrefetchAttempt({
908
+ matchId: outcome.matchId,
909
+ ...key,
910
+ status: result.status === 'FETCHED' ? 'FETCHED' : result.status,
911
+ attemptCount,
912
+ lastAttemptedAt: started.toISOString(),
913
+ nextRetryAt: result.status === 'UNAVAILABLE' ? retryAt(started, attemptCount) : null,
914
+ ...(result.error ? { error: result.error } : {}),
915
+ updatedAt: started.toISOString(),
916
+ });
917
+ }
918
+ const diagnosticFailedIds = Array.isArray(source.diagnostics.failedMatchIds)
919
+ ? source.diagnostics.failedMatchIds.map(String)
920
+ : [];
921
+ const failedMatchIds = [...new Set([...diagnosticFailedIds, ...unavailableIds])];
922
+ const finished = this.now();
923
+ const run = {
924
+ runId: 'tp_' + randomUUID(),
925
+ reportDate: localDateLabel(started.getTime(), this.timezone),
926
+ startedAt: started.toISOString(),
927
+ finishedAt: finished.toISOString(),
928
+ trigger: input.trigger ?? 'hourly',
929
+ discoveredMatchCount: Number(source.diagnostics.discoveredMatchCount ?? source.newMatchIds.length),
930
+ newMatchCount: source.newMatchIds.length,
931
+ candidateMatchCount: candidateMatches.length,
932
+ fetchedCount,
933
+ cacheHitCount,
934
+ unavailableCount,
935
+ pendingCount: deferred.length + unavailableCount,
936
+ failedMatchIds,
937
+ };
938
+ await this.repository.recordTelemetryPrefetchRun(run);
939
+ const status = source.coverage.status === 'SOURCE_UNAVAILABLE' ||
940
+ run.unavailableCount > 0 ||
941
+ run.pendingCount > 0
942
+ ? 'partial'
943
+ : 'ok';
944
+ return {
945
+ status,
946
+ data: {
947
+ run,
948
+ matchSync: {
949
+ coverage: source.coverage,
950
+ source: source.source,
951
+ diagnostics: source.diagnostics,
952
+ },
953
+ parserVersion: key.parserVersion,
954
+ featureVersion: key.featureVersion,
955
+ },
956
+ coverage: source.coverage,
957
+ asOf: finished.toISOString(),
958
+ metricVersion: PUBGMETRIC_VERSION,
959
+ queryResolved: {
960
+ tool: 'pubg_prefetch_telemetry',
961
+ timezone: this.timezone,
962
+ maxMatches,
963
+ maxFetches,
964
+ concurrency,
965
+ trigger: run.trigger,
966
+ },
967
+ evidenceRefs: {
968
+ matchIds: [...new Set([...candidateIds, ...failedMatchIds])].slice(0, 1000),
969
+ playerIds: this.team.players.map((player) => player.id),
970
+ fields: ['matchSync', 'telemetryFeatures', 'prefetchAttempts', 'retrySchedule'],
971
+ calculation: 'pubg_hourly_telemetry_prefetch_v1',
972
+ },
973
+ ...(source.coverage.status === 'SOURCE_UNAVAILABLE'
974
+ ? {
975
+ error: {
976
+ code: 'source_unavailable',
977
+ retryable: true,
978
+ reason: source.source.error ?? 'PUBG API unavailable',
979
+ },
980
+ }
981
+ : {}),
982
+ };
983
+ }
984
+ async getTelemetrySyncReport(input = {}) {
985
+ const now = this.now();
986
+ const reportDate = input.reportDate?.trim() || previousLocalDate(now, this.timezone);
987
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(reportDate) ||
988
+ !Number.isFinite(Date.parse(`${reportDate}T00:00:00Z`))) {
989
+ return this.errorEnvelope('invalid_report_date', false, 'reportDate must be YYYY-MM-DD');
990
+ }
991
+ const runs = await this.repository.listTelemetryPrefetchRuns(reportDate);
992
+ const summary = summarizePrefetchRuns(reportDate, this.timezone, runs);
993
+ const asOf = summary.lastRunAt ?? now.toISOString();
994
+ const status = summary.unavailableCount || summary.pendingCount || summary.failedMatchIds.length
995
+ ? 'partial'
996
+ : 'ok';
997
+ const [matches, syncState] = await Promise.all([
998
+ this.repository.listMatches(),
999
+ this.repository.getSyncState('team:' + this.team.id),
1000
+ ]);
1001
+ return {
1002
+ status,
1003
+ data: {
1004
+ summary,
1005
+ runs,
1006
+ },
1007
+ coverage: coverageForLocal(matches, now, syncState),
1008
+ asOf,
1009
+ metricVersion: PUBGMETRIC_VERSION,
1010
+ queryResolved: {
1011
+ tool: 'pubg_telemetry_sync_report',
1012
+ reportDate,
1013
+ timezone: this.timezone,
1014
+ period: 'previous_calendar_day',
1015
+ },
1016
+ evidenceRefs: {
1017
+ matchIds: summary.failedMatchIds.slice(0, 1000),
1018
+ playerIds: this.team.players.map((player) => player.id),
1019
+ fields: [
1020
+ 'prefetchRuns',
1021
+ 'fetchedCount',
1022
+ 'cacheHitCount',
1023
+ 'unavailableCount',
1024
+ 'pendingCount',
1025
+ 'dataUpdatedAt',
1026
+ ],
1027
+ calculation: 'pubg_daily_telemetry_sync_report_v1',
1028
+ },
1029
+ };
1030
+ }
1031
+ async getMatch(input) {
1032
+ let match = await this.repository.getMatch(input.matchId);
1033
+ const [initialCount, initialMatches, initialState] = await Promise.all([
1034
+ this.repository.countMatches(),
1035
+ this.repository.listMatches(),
1036
+ this.repository.getSyncState('team:' + this.team.id),
1037
+ ]);
1038
+ let source = {
1039
+ store: this.repository.store,
1040
+ syncInvoked: false,
1041
+ playerApiCalls: 0,
1042
+ matchApiCalls: 0,
1043
+ localMatchCount: initialCount,
1044
+ };
1045
+ let coverage = coverageForLocal(initialMatches, this.now(), initialState);
1046
+ if (!match && input.refresh !== false && this.apiClient) {
1047
+ try {
1048
+ const fetched = await this.apiClient.getMatch(this.team.platform, input.matchId, this.team, input.signal);
1049
+ await this.repository.upsertMatches([fetched], {
1050
+ source: 'pubg-api',
1051
+ fetchedAt: this.now().toISOString(),
1052
+ checkedPlayerIds: this.team.players.map((player) => player.id),
1053
+ });
1054
+ match = fetched;
1055
+ const [matchCount, matches] = await Promise.all([
1056
+ this.repository.countMatches(),
1057
+ this.repository.listMatches(),
1058
+ ]);
1059
+ source = {
1060
+ store: 'pubg-api',
1061
+ syncInvoked: true,
1062
+ playerApiCalls: 0,
1063
+ matchApiCalls: 1,
1064
+ localMatchCount: matchCount,
1065
+ };
1066
+ coverage = coverageForLocal(matches, this.now(), null);
1067
+ }
1068
+ catch (error) {
1069
+ const apiError = error instanceof PubgApiError;
1070
+ return this.errorEnvelope(apiError ? error.code : 'match_fetch_failed', apiError ? error.retryable : true, error instanceof Error ? error.message : 'match fetch failed');
1071
+ }
1072
+ }
1073
+ if (!match)
1074
+ return this.errorEnvelope('match_not_found', false, input.matchId);
1075
+ const row = {
1076
+ key: match.matchId,
1077
+ label: match.matchId,
1078
+ groupBy: 'match',
1079
+ matchId: match.matchId,
1080
+ timestamp: match.timestamp,
1081
+ createdAt: match.createdAt,
1082
+ mapName: match.mapName,
1083
+ gameMode: match.gameMode,
1084
+ duration: match.duration,
1085
+ players: match.players,
1086
+ metrics: {
1087
+ matches: 1,
1088
+ kills: match.players.reduce((sum, player) => sum + player.kills, 0),
1089
+ assists: match.players.reduce((sum, player) => sum + player.assists, 0),
1090
+ damage: match.players.reduce((sum, player) => sum + player.damage, 0),
1091
+ rank: match.players
1092
+ .map((player) => player.rank)
1093
+ .filter((rank) => rank !== null)
1094
+ .sort((a, b) => a - b)[0] ?? null,
1095
+ },
1096
+ activityStatus: 'ACTIVE',
1097
+ };
1098
+ return {
1099
+ status: 'ok',
1100
+ data: { match: sanitize({ ...match, telemetryUrl: undefined, row }) },
1101
+ coverage,
1102
+ asOf: this.now().toISOString(),
1103
+ metricVersion: PUBGMETRIC_VERSION,
1104
+ queryResolved: {
1105
+ tool: 'pubg_get_match',
1106
+ matchId: input.matchId,
1107
+ sourceRange: (() => {
1108
+ const from = match.createdAt ??
1109
+ (Number.isFinite(match.timestamp) ? new Date(match.timestamp).toISOString() : null);
1110
+ const fromTimestamp = from ? Date.parse(from) : Number.NaN;
1111
+ const to = Number.isFinite(fromTimestamp) && Number.isFinite(match.duration)
1112
+ ? new Date(fromTimestamp + Math.max(0, match.duration) * 1000).toISOString()
1113
+ : null;
1114
+ return { from, to, timezone: this.timezone, businessDayStart: this.businessDayStart };
1115
+ })(),
1116
+ },
1117
+ evidenceRefs: {
1118
+ matchIds: [match.matchId],
1119
+ playerIds: match.players.map((player) => player.accountId),
1120
+ fields: ['matchId', 'createdAt', 'mapName', 'gameMode', 'players'],
1121
+ calculation: 'sqlite_match_store',
1122
+ },
1123
+ };
1124
+ }
1125
+ async getReviewFacts(input) {
1126
+ const searchResultSet = await this.repository.getResultSet(input.sessionId, input.searchResultSetId, this.now());
1127
+ const searchCreatedAt = searchResultSet ? Date.parse(searchResultSet.createdAt) : Number.NaN;
1128
+ const searchAge = Number.isFinite(searchCreatedAt)
1129
+ ? this.now().getTime() - searchCreatedAt
1130
+ : Number.POSITIVE_INFINITY;
1131
+ if (!searchResultSet ||
1132
+ !searchResultSet.source.syncInvoked ||
1133
+ searchAge < 0 ||
1134
+ searchAge > PUBG_REVIEW_SEARCH_MAX_AGE_MS) {
1135
+ return this.errorEnvelope('review_search_required', true, 'call pubg_search_matches with refresh=true in the current turn, then pass its resultSetId to pubg_get_review_facts');
1136
+ }
1137
+ if (!searchResultSet.matchIds.includes(input.matchId)) {
1138
+ return this.errorEnvelope('match_not_in_search_result', false, input.matchId);
1139
+ }
1140
+ const match = await this.repository.getMatch(input.matchId);
1141
+ if (!match) {
1142
+ const result = await this.getMatch({
1143
+ sessionId: input.sessionId,
1144
+ matchId: input.matchId,
1145
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
1146
+ ...(input.signal ? { signal: input.signal } : {}),
1147
+ });
1148
+ if (result.status === 'error')
1149
+ return result;
1150
+ }
1151
+ const target = await this.repository.getMatch(input.matchId);
1152
+ if (!target)
1153
+ return this.errorEnvelope('match_not_found', false, input.matchId);
1154
+ const telemetry = await this.telemetryWorker.ensure(target, 1, input.signal);
1155
+ const fullFacts = input.playerIds?.length
1156
+ ? scopeMatchReviewFacts(telemetry.facts ?? emptyMatchReviewFacts(target, this.team, 1), input.playerIds)
1157
+ : (telemetry.facts ?? emptyMatchReviewFacts(target, this.team, 1));
1158
+ const facts = selectMatchReviewFactCategories(fullFacts, input.categories);
1159
+ const analysis = analyzeMatchReview(facts);
1160
+ const review = {
1161
+ schemaVersion: 1,
1162
+ match: facts.match,
1163
+ facts,
1164
+ analysis,
1165
+ telemetry: {
1166
+ status: telemetry.status,
1167
+ cacheStatus: telemetry.cacheStatus,
1168
+ cacheLookup: telemetry.cacheLookup,
1169
+ availability: telemetry.availability,
1170
+ parserVersion: telemetry.parserVersion,
1171
+ featureVersion: telemetry.featureVersion,
1172
+ ...(telemetry.error ? { error: telemetry.error } : {}),
1173
+ },
1174
+ };
1175
+ const status = telemetry.status === 'UNAVAILABLE' ? 'partial' : facts.fightIntegrity.pass ? 'ok' : 'partial';
1176
+ const sourceRange = searchResultSet
1177
+ ? sourceRangeFromSelector(searchResultSet.resolvedSelector)
1178
+ : null;
1179
+ const [allMatches, syncState] = await Promise.all([
1180
+ this.repository.listMatches(),
1181
+ this.repository.getSyncState('team:' + this.team.id),
1182
+ ]);
1183
+ const result = {
1184
+ status,
1185
+ data: {
1186
+ facts: sanitize(facts),
1187
+ telemetry: sanitize(review.telemetry),
1188
+ derivedAnalysis: sanitize(analysis),
1189
+ },
1190
+ coverage: coverageForLocal(allMatches, this.now(), syncState),
1191
+ asOf: this.now().toISOString(),
1192
+ metricVersion: PUBGMETRIC_VERSION,
1193
+ queryResolved: {
1194
+ tool: 'pubg_get_review_facts',
1195
+ matchId: input.matchId,
1196
+ searchResultSetId: input.searchResultSetId,
1197
+ playerIds: input.playerIds ?? null,
1198
+ categories: input.categories ?? null,
1199
+ ...(sourceRange ? { sourceRange } : {}),
1200
+ },
1201
+ evidenceRefs: {
1202
+ matchIds: [target.matchId],
1203
+ playerIds: facts.squad.playerIds,
1204
+ fields: ['match', 'players', 'combat', 'fights', 'weapons', 'vehicles', 'evidence'],
1205
+ calculation: 'telemetry_facts_v1',
1206
+ },
1207
+ };
1208
+ if (telemetry.status === 'UNAVAILABLE')
1209
+ result.error = {
1210
+ code: 'telemetry_unavailable',
1211
+ retryable: true,
1212
+ reason: telemetry.error ?? 'telemetry unavailable',
1213
+ };
1214
+ return result;
1215
+ }
1216
+ async getPeriodReview(input) {
1217
+ const now = this.now();
1218
+ const searchResultSet = await this.repository.getResultSet(input.sessionId, input.searchResultSetId, now);
1219
+ const searchCreatedAt = searchResultSet ? Date.parse(searchResultSet.createdAt) : Number.NaN;
1220
+ const searchAge = Number.isFinite(searchCreatedAt)
1221
+ ? now.getTime() - searchCreatedAt
1222
+ : Number.POSITIVE_INFINITY;
1223
+ if (!searchResultSet ||
1224
+ !searchResultSet.source.syncInvoked ||
1225
+ searchAge < 0 ||
1226
+ searchAge > PUBG_REVIEW_SEARCH_MAX_AGE_MS) {
1227
+ return this.errorEnvelope('review_search_required', true, 'call pubg_search_matches with refresh=true in the current turn, then pass its resultSetId to pubg_get_period_review');
1228
+ }
1229
+ const sourceRange = sourceRangeFromSelector(searchResultSet.resolvedSelector);
1230
+ const orderedMatchIds = [...searchResultSet.matchIds];
1231
+ const reviews = [];
1232
+ const selectedMatches = new Map();
1233
+ for (const matchId of orderedMatchIds) {
1234
+ const review = await this.getReviewFacts({
1235
+ sessionId: input.sessionId,
1236
+ matchId,
1237
+ searchResultSetId: input.searchResultSetId,
1238
+ ...(input.playerIds?.length ? { playerIds: input.playerIds } : {}),
1239
+ ...(input.categories?.length ? { categories: input.categories } : {}),
1240
+ ...(input.signal ? { signal: input.signal } : {}),
1241
+ });
1242
+ const match = await this.repository.getMatch(matchId);
1243
+ if (match)
1244
+ selectedMatches.set(matchId, match);
1245
+ reviews.push({
1246
+ matchId,
1247
+ status: review.status,
1248
+ data: sanitize(review.data),
1249
+ ...(review.error ? { error: review.error } : {}),
1250
+ ...(match
1251
+ ? {
1252
+ match: {
1253
+ matchId: match.matchId,
1254
+ createdAt: match.createdAt,
1255
+ mapName: match.mapName,
1256
+ gameMode: match.gameMode,
1257
+ duration: match.duration,
1258
+ },
1259
+ }
1260
+ : {}),
1261
+ });
1262
+ }
1263
+ const partial = reviews.some((review) => review.status !== 'ok') || searchResultSet.status !== 'OK';
1264
+ const status = orderedMatchIds.length === 0 ? 'no_matches' : partial ? 'partial' : 'ok';
1265
+ return {
1266
+ status,
1267
+ data: {
1268
+ period: {
1269
+ label: typeof searchResultSet.resolvedSelector.label === 'string'
1270
+ ? searchResultSet.resolvedSelector.label
1271
+ : 'PUBG 周期复盘',
1272
+ orderedMatchIds,
1273
+ },
1274
+ reviews,
1275
+ matches: orderedMatchIds.map((matchId) => {
1276
+ const match = selectedMatches.get(matchId);
1277
+ return match
1278
+ ? {
1279
+ matchId,
1280
+ startedAt: match.createdAt,
1281
+ mapName: match.mapName,
1282
+ gameMode: match.gameMode,
1283
+ }
1284
+ : { matchId, startedAt: null, mapName: '未知地图', gameMode: '未知模式' };
1285
+ }),
1286
+ },
1287
+ coverage: searchResultSet.coverage,
1288
+ asOf: now.toISOString(),
1289
+ metricVersion: PUBGMETRIC_VERSION,
1290
+ queryResolved: {
1291
+ tool: 'pubg_get_period_review',
1292
+ sessionId: input.sessionId,
1293
+ searchResultSetId: input.searchResultSetId,
1294
+ selector: searchResultSet.resolvedSelector,
1295
+ order: orderedMatchIds,
1296
+ ...(sourceRange ? { sourceRange } : {}),
1297
+ categories: input.categories ?? null,
1298
+ },
1299
+ evidenceRefs: {
1300
+ matchIds: orderedMatchIds,
1301
+ playerIds: input.playerIds ?? searchResultSet.playerIds,
1302
+ fields: ['orderedMatchIds', 'reviews', 'telemetry', 'dataUpdatedAt'],
1303
+ calculation: 'pubg_period_review_v1',
1304
+ },
1305
+ ...(partial
1306
+ ? {
1307
+ error: {
1308
+ code: 'period_review_partial',
1309
+ retryable: true,
1310
+ reason: 'one or more selected matches did not have complete review facts',
1311
+ },
1312
+ }
1313
+ : {}),
1314
+ resultSetId: input.searchResultSetId,
1315
+ };
1316
+ }
1317
+ async queryTeamDamage(input) {
1318
+ const actorReference = input.actorPlayer?.trim() || null;
1319
+ const victimReference = input.victimPlayer?.trim() || null;
1320
+ if (Boolean(actorReference) !== Boolean(victimReference)) {
1321
+ return this.errorEnvelope('team_damage_direction_incomplete', false, 'actorPlayer and victimPlayer must be provided together, or both omitted for all directions');
1322
+ }
1323
+ if (input.meleeKind && input.source && input.source !== 'MELEE') {
1324
+ return this.errorEnvelope('team_damage_source_conflict', false, 'meleeKind requires source=MELEE');
1325
+ }
1326
+ let actor;
1327
+ let victim;
1328
+ if (actorReference && victimReference) {
1329
+ const actorResult = this.resolveSubject({ playerNames: [actorReference] });
1330
+ if (actorResult.error)
1331
+ return this.errorEnvelope(actorResult.error.code, actorResult.error.retryable, actorResult.error.reason);
1332
+ actor = actorResult.players[0];
1333
+ const victimResult = this.resolveSubject({ playerNames: [victimReference] });
1334
+ if (victimResult.error)
1335
+ return this.errorEnvelope(victimResult.error.code, victimResult.error.retryable, victimResult.error.reason);
1336
+ victim = victimResult.players[0];
1337
+ if (!actor || !victim)
1338
+ return this.errorEnvelope('unknown_player', false, `${actorReference},${victimReference}`);
1339
+ if (actor.id === victim.id)
1340
+ return this.errorEnvelope('team_damage_same_player', false, 'actorPlayer and victimPlayer must be different team players');
1341
+ }
1342
+ const now = this.now();
1343
+ const canonicalSelector = selectorToCanonical(input.selector, this.timezone, this.businessDayStart);
1344
+ let resolvedSelector;
1345
+ try {
1346
+ resolvedSelector = resolveSelector(canonicalSelector, {
1347
+ timezone: this.timezone,
1348
+ businessDayStart: this.businessDayStart,
1349
+ now,
1350
+ });
1351
+ }
1352
+ catch {
1353
+ return this.errorEnvelope('invalid_time_range', false, 'selector must resolve to a valid time range');
1354
+ }
1355
+ const from = Date.parse(resolvedSelector.start);
1356
+ const to = Date.parse(resolvedSelector.end);
1357
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to) {
1358
+ return this.errorEnvelope('invalid_time_range', false, 'selector must resolve to a non-empty time range');
1359
+ }
1360
+ const matchSource = await this.refresh(input.refresh !== false, this.maxMatches, input.signal);
1361
+ const teamIds = new Set(this.team.players.map((player) => player.id));
1362
+ const directionIds = actor && victim ? new Set([actor.id, victim.id]) : null;
1363
+ const selected = matchSource.records
1364
+ .filter((match) => match.timestamp >= from && match.timestamp < to && match.isCompetitive !== false)
1365
+ .filter((match) => match.players.some((player) => teamIds.has(player.accountId)))
1366
+ .filter((match) => !directionIds || match.players.some((player) => directionIds.has(player.accountId)))
1367
+ .sort((left, right) => left.timestamp - right.timestamp || left.matchId.localeCompare(right.matchId));
1368
+ const effectiveSource = input.meleeKind ? 'MELEE' : input.source;
1369
+ const matchInputs = selected.map((match, index) => ({ match, ordinal: index + 1 }));
1370
+ const outcomes = await mapWithConcurrency(matchInputs, 2, async ({ match, ordinal }) => {
1371
+ const telemetry = await this.telemetryWorker.ensure(match, ordinal, input.signal);
1372
+ const facts = telemetry.facts?.teamDamage;
1373
+ const matchingFacts = facts
1374
+ ? facts
1375
+ .filter((fact) => teamDamageFactMatches(fact, actor?.id, victim?.id, effectiveSource, input.meleeKind))
1376
+ .sort((left, right) => (left.timestamp ?? Number.POSITIVE_INFINITY) -
1377
+ (right.timestamp ?? Number.POSITIVE_INFINITY) || left.id.localeCompare(right.id))
1378
+ : null;
1379
+ return { match, telemetry, matchingFacts };
1380
+ });
1381
+ const unavailableMatchIds = outcomes
1382
+ .filter((outcome) => outcome.matchingFacts === null)
1383
+ .map((outcome) => outcome.match.matchId);
1384
+ const telemetryComplete = unavailableMatchIds.length === 0;
1385
+ const telemetryStatusCounts = outcomes.reduce((counts, outcome) => {
1386
+ counts[outcome.telemetry.status] = (counts[outcome.telemetry.status] ?? 0) + 1;
1387
+ return counts;
1388
+ }, {});
1389
+ const playerRef = (player) => ({
1390
+ playerId: player.id,
1391
+ name: player.name,
1392
+ });
1393
+ const nameForPlayerId = (playerId) => {
1394
+ const player = this.team.players.find((candidate) => candidate.id === playerId);
1395
+ return { playerId, name: player?.name ?? playerId };
1396
+ };
1397
+ const directionAggregates = new Map();
1398
+ for (const outcome of outcomes) {
1399
+ for (const fact of outcome.matchingFacts ?? []) {
1400
+ const key = `${fact.actorPlayerId}:${fact.victimPlayerId}`;
1401
+ const aggregate = directionAggregates.get(key) ?? {
1402
+ actorPlayerId: fact.actorPlayerId,
1403
+ victimPlayerId: fact.victimPlayerId,
1404
+ hitCount: 0,
1405
+ damage: 0,
1406
+ matchIds: [],
1407
+ };
1408
+ aggregate.hitCount += fact.hitCount;
1409
+ aggregate.damage += fact.damage;
1410
+ if (!aggregate.matchIds.includes(outcome.match.matchId))
1411
+ aggregate.matchIds.push(outcome.match.matchId);
1412
+ directionAggregates.set(key, aggregate);
1413
+ }
1414
+ }
1415
+ if (actor && victim) {
1416
+ const key = `${actor.id}:${victim.id}`;
1417
+ if (!directionAggregates.has(key)) {
1418
+ directionAggregates.set(key, {
1419
+ actorPlayerId: actor.id,
1420
+ victimPlayerId: victim.id,
1421
+ hitCount: 0,
1422
+ damage: 0,
1423
+ matchIds: [],
1424
+ });
1425
+ }
1426
+ }
1427
+ const knownHitCount = outcomes.reduce((sum, outcome) => sum + (outcome.matchingFacts?.reduce((inner, fact) => inner + fact.hitCount, 0) ?? 0), 0);
1428
+ const knownDamage = roundDamage(outcomes.reduce((sum, outcome) => sum + (outcome.matchingFacts?.reduce((inner, fact) => inner + fact.damage, 0) ?? 0), 0));
1429
+ const matchRows = outcomes.map((outcome) => {
1430
+ const players = outcome.match.players.filter((player) => teamIds.has(player.accountId));
1431
+ const ranks = players
1432
+ .map((player) => player.rank)
1433
+ .filter((rank) => rank !== null);
1434
+ const hitCount = outcome.matchingFacts?.reduce((sum, fact) => sum + fact.hitCount, 0) ?? null;
1435
+ const damage = outcome.matchingFacts === null
1436
+ ? null
1437
+ : roundDamage(outcome.matchingFacts.reduce((sum, fact) => sum + fact.damage, 0));
1438
+ return {
1439
+ matchId: outcome.match.matchId,
1440
+ startedAt: outcome.match.createdAt,
1441
+ mapName: outcome.match.mapName,
1442
+ gameMode: outcome.match.gameMode,
1443
+ placement: ranks.length ? Math.min(...ranks) : null,
1444
+ hitCount,
1445
+ damage,
1446
+ teamDamage: sanitize(outcome.matchingFacts),
1447
+ telemetry: {
1448
+ status: outcome.telemetry.status,
1449
+ cacheStatus: outcome.telemetry.cacheStatus,
1450
+ cacheLookup: outcome.telemetry.cacheLookup,
1451
+ availability: outcome.telemetry.availability,
1452
+ ...(outcome.telemetry.error ? { error: outcome.telemetry.error } : {}),
1453
+ },
1454
+ };
1455
+ });
1456
+ const directionRows = [...directionAggregates.values()]
1457
+ .sort((left, right) => right.hitCount - left.hitCount ||
1458
+ right.damage - left.damage ||
1459
+ left.actorPlayerId.localeCompare(right.actorPlayerId) ||
1460
+ left.victimPlayerId.localeCompare(right.victimPlayerId))
1461
+ .map((aggregate) => ({
1462
+ actor: nameForPlayerId(aggregate.actorPlayerId),
1463
+ victim: nameForPlayerId(aggregate.victimPlayerId),
1464
+ hitCount: telemetryComplete ? aggregate.hitCount : null,
1465
+ damage: telemetryComplete ? roundDamage(aggregate.damage) : null,
1466
+ knownHitCount: aggregate.hitCount,
1467
+ knownDamage: roundDamage(aggregate.damage),
1468
+ complete: telemetryComplete,
1469
+ matchIds: aggregate.matchIds,
1470
+ }));
1471
+ const sourceRows = TEAM_DAMAGE_SOURCES.flatMap((source) => {
1472
+ const facts = outcomes
1473
+ .flatMap((outcome) => outcome.matchingFacts ?? [])
1474
+ .filter((fact) => fact.source === source);
1475
+ if (!facts.length)
1476
+ return [];
1477
+ const hitCount = facts.reduce((sum, fact) => sum + fact.hitCount, 0);
1478
+ const damage = roundDamage(facts.reduce((sum, fact) => sum + fact.damage, 0));
1479
+ return [
1480
+ {
1481
+ source,
1482
+ hitCount: telemetryComplete ? hitCount : null,
1483
+ damage: telemetryComplete ? damage : null,
1484
+ knownHitCount: hitCount,
1485
+ knownDamage: damage,
1486
+ },
1487
+ ];
1488
+ });
1489
+ const coverage = unavailableMatchIds.length
1490
+ ? {
1491
+ ...matchSource.coverage,
1492
+ status: matchSource.coverage.status === 'OK' ? 'PARTIAL' : matchSource.coverage.status,
1493
+ complete: false,
1494
+ failedMatchIds: [
1495
+ ...new Set([...matchSource.coverage.failedMatchIds, ...unavailableMatchIds]),
1496
+ ],
1497
+ requiredMatchCount: Math.max(matchSource.coverage.requiredMatchCount ?? 0, selected.length),
1498
+ }
1499
+ : matchSource.coverage;
1500
+ const status = selected.length === 0
1501
+ ? matchSource.coverage.complete
1502
+ ? 'no_matches'
1503
+ : 'partial'
1504
+ : telemetryComplete && matchSource.coverage.status === 'OK'
1505
+ ? 'ok'
1506
+ : 'partial';
1507
+ const sourceRange = {
1508
+ from: resolvedSelector.start,
1509
+ to: resolvedSelector.end,
1510
+ timezone: resolvedSelector.timezone,
1511
+ businessDayStart: resolvedSelector.businessDayStart,
1512
+ };
1513
+ const result = {
1514
+ status,
1515
+ data: sanitize({
1516
+ actor: actor ? playerRef(actor) : null,
1517
+ victim: victim ? playerRef(victim) : null,
1518
+ direction: actor && victim ? `${actor.name} → ${victim.name}` : 'all directions',
1519
+ source: effectiveSource ?? null,
1520
+ meleeKind: input.meleeKind ?? null,
1521
+ totalHitCount: telemetryComplete ? knownHitCount : null,
1522
+ totalDamage: telemetryComplete ? knownDamage : null,
1523
+ knownHitCount,
1524
+ knownDamage,
1525
+ complete: telemetryComplete,
1526
+ directions: directionRows,
1527
+ bySource: sourceRows,
1528
+ matches: matchRows,
1529
+ telemetry: {
1530
+ requestedMatchCount: selected.length,
1531
+ availableMatchCount: selected.length - unavailableMatchIds.length,
1532
+ unavailableMatchIds,
1533
+ statusCounts: telemetryStatusCounts,
1534
+ },
1535
+ }),
1536
+ coverage,
1537
+ asOf: this.now().toISOString(),
1538
+ metricVersion: PUBGMETRIC_VERSION,
1539
+ queryResolved: {
1540
+ tool: 'pubg_query_team_damage',
1541
+ sessionId: input.sessionId,
1542
+ selector: {
1543
+ ...input.selector,
1544
+ from: resolvedSelector.start,
1545
+ to: resolvedSelector.end,
1546
+ timezone: resolvedSelector.timezone,
1547
+ businessDayStart: resolvedSelector.businessDayStart,
1548
+ },
1549
+ sourceRange,
1550
+ direction: actor && victim ? { actorPlayerId: actor.id, victimPlayerId: victim.id } : null,
1551
+ source: effectiveSource ?? null,
1552
+ meleeKind: input.meleeKind ?? null,
1553
+ matchCount: selected.length,
1554
+ refresh: {
1555
+ requested: input.refresh !== false,
1556
+ syncInvoked: matchSource.source.syncInvoked,
1557
+ playerApiCalls: matchSource.source.playerApiCalls,
1558
+ matchApiCalls: matchSource.source.matchApiCalls,
1559
+ newMatchCount: matchSource.diagnostics.newMatchCount ?? 0,
1560
+ cachedMatchCount: matchSource.diagnostics.cachedMatchCount ?? 0,
1561
+ cacheReason: matchSource.diagnostics.reason ?? null,
1562
+ },
1563
+ telemetry: {
1564
+ complete: telemetryComplete,
1565
+ unavailableMatchIds,
1566
+ statusCounts: telemetryStatusCounts,
1567
+ },
1568
+ },
1569
+ evidenceRefs: {
1570
+ matchIds: selected.map((match) => match.matchId),
1571
+ playerIds: actor && victim ? [actor.id, victim.id] : this.team.players.map((player) => player.id),
1572
+ fields: [
1573
+ 'matchId',
1574
+ 'startedAt',
1575
+ 'mapName',
1576
+ 'placement',
1577
+ 'actorPlayerId',
1578
+ 'victimPlayerId',
1579
+ 'source',
1580
+ 'meleeKind',
1581
+ 'hitCount',
1582
+ 'damage',
1583
+ 'phase',
1584
+ 'evidenceIds',
1585
+ ],
1586
+ calculation: 'pubg_team_damage_query_v1',
1587
+ },
1588
+ };
1589
+ if (unavailableMatchIds.length)
1590
+ result.error = {
1591
+ code: 'telemetry_unavailable',
1592
+ retryable: true,
1593
+ reason: `${unavailableMatchIds.length} selected match(es) have unavailable Telemetry`,
1594
+ };
1595
+ return result;
1596
+ }
1597
+ }
1598
+ //# sourceMappingURL=pubg-domain-service.js.map