@rine-kit/pubg-intelligence 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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/analytics.d.ts +176 -0
  3. package/dist/src/analytics.js +431 -0
  4. package/dist/src/analytics.js.map +1 -0
  5. package/dist/src/assistant.d.ts +87 -0
  6. package/dist/src/assistant.js +310 -0
  7. package/dist/src/assistant.js.map +1 -0
  8. package/dist/src/battle.d.ts +280 -0
  9. package/dist/src/battle.js +1249 -0
  10. package/dist/src/battle.js.map +1 -0
  11. package/dist/src/combat-long.d.ts +42 -0
  12. package/dist/src/combat-long.js +146 -0
  13. package/dist/src/combat-long.js.map +1 -0
  14. package/dist/src/combat-quality.d.ts +101 -0
  15. package/dist/src/combat-quality.js +253 -0
  16. package/dist/src/combat-quality.js.map +1 -0
  17. package/dist/src/evaluation-fixtures.d.ts +13 -0
  18. package/dist/src/evaluation-fixtures.js +144 -0
  19. package/dist/src/evaluation-fixtures.js.map +1 -0
  20. package/dist/src/evaluation-replay.d.ts +5 -0
  21. package/dist/src/evaluation-replay.js +107 -0
  22. package/dist/src/evaluation-replay.js.map +1 -0
  23. package/dist/src/evaluation.d.ts +140 -0
  24. package/dist/src/evaluation.js +180 -0
  25. package/dist/src/evaluation.js.map +1 -0
  26. package/dist/src/experiment-store.d.ts +56 -0
  27. package/dist/src/experiment-store.js +159 -0
  28. package/dist/src/experiment-store.js.map +1 -0
  29. package/dist/src/field.d.ts +103 -0
  30. package/dist/src/field.js +298 -0
  31. package/dist/src/field.js.map +1 -0
  32. package/dist/src/fights.d.ts +89 -0
  33. package/dist/src/fights.js +238 -0
  34. package/dist/src/fights.js.map +1 -0
  35. package/dist/src/highlights.d.ts +44 -0
  36. package/dist/src/highlights.js +153 -0
  37. package/dist/src/highlights.js.map +1 -0
  38. package/dist/src/index.d.ts +24 -0
  39. package/dist/src/index.js +25 -0
  40. package/dist/src/index.js.map +1 -0
  41. package/dist/src/insights.d.ts +44 -0
  42. package/dist/src/insights.js +343 -0
  43. package/dist/src/insights.js.map +1 -0
  44. package/dist/src/intent.d.ts +40 -0
  45. package/dist/src/intent.js +119 -0
  46. package/dist/src/intent.js.map +1 -0
  47. package/dist/src/landing.d.ts +74 -0
  48. package/dist/src/landing.js +195 -0
  49. package/dist/src/landing.js.map +1 -0
  50. package/dist/src/lobby.d.ts +48 -0
  51. package/dist/src/lobby.js +209 -0
  52. package/dist/src/lobby.js.map +1 -0
  53. package/dist/src/match-insight.d.ts +165 -0
  54. package/dist/src/match-insight.js +542 -0
  55. package/dist/src/match-insight.js.map +1 -0
  56. package/dist/src/match-relative.d.ts +73 -0
  57. package/dist/src/match-relative.js +145 -0
  58. package/dist/src/match-relative.js.map +1 -0
  59. package/dist/src/match-story.d.ts +155 -0
  60. package/dist/src/match-story.js +636 -0
  61. package/dist/src/match-story.js.map +1 -0
  62. package/dist/src/profiles.d.ts +41 -0
  63. package/dist/src/profiles.js +136 -0
  64. package/dist/src/profiles.js.map +1 -0
  65. package/dist/src/raw-telemetry-archive.d.ts +29 -0
  66. package/dist/src/raw-telemetry-archive.js +340 -0
  67. package/dist/src/raw-telemetry-archive.js.map +1 -0
  68. package/dist/src/raw-telemetry-backfill.d.ts +28 -0
  69. package/dist/src/raw-telemetry-backfill.js +144 -0
  70. package/dist/src/raw-telemetry-backfill.js.map +1 -0
  71. package/dist/src/research.d.ts +89 -0
  72. package/dist/src/research.js +376 -0
  73. package/dist/src/research.js.map +1 -0
  74. package/dist/src/rhythm.d.ts +104 -0
  75. package/dist/src/rhythm.js +260 -0
  76. package/dist/src/rhythm.js.map +1 -0
  77. package/dist/src/streaks.d.ts +117 -0
  78. package/dist/src/streaks.js +367 -0
  79. package/dist/src/streaks.js.map +1 -0
  80. package/dist/src/style.d.ts +304 -0
  81. package/dist/src/style.js +1461 -0
  82. package/dist/src/style.js.map +1 -0
  83. package/dist/src/tactics.d.ts +88 -0
  84. package/dist/src/tactics.js +1221 -0
  85. package/dist/src/tactics.js.map +1 -0
  86. package/package.json +49 -3
  87. package/README.md +0 -3
@@ -0,0 +1,28 @@
1
+ import type { NormalizedMatch, RawTelemetryArchivePort } from '@rine-kit/pubg-domain';
2
+ /**
3
+ * PUBG's telemetry CDN only serves a match for roughly two weeks, so the useful window is whatever the CDN still
4
+ * answers for. The cap bounds one run; expired assets (HTTP 403/404/410) are counted as missing, not retried.
5
+ */
6
+ export declare const HISTORICAL_TELEMETRY_BACKFILL_LIMIT = 500;
7
+ export declare function isExpiredTelemetryAsset(error: unknown): boolean;
8
+ /** Resolve symlinks in existing parents and keep private captures outside Git. */
9
+ export declare function assertArchiveOutsideRepository(archiveDirectory: string, repositoryRoot: string): Promise<string>;
10
+ export interface RawTelemetryBackfillResult {
11
+ selectedMatchCount: number;
12
+ eligibleMatchCount: number;
13
+ alreadyArchivedCount: number;
14
+ archivedCount: number;
15
+ missingAssetCount: number;
16
+ failedCount: number;
17
+ }
18
+ export interface RawTelemetryBackfillOptions {
19
+ limit?: number;
20
+ retries?: number;
21
+ concurrency?: number;
22
+ fetchRaw(match: NormalizedMatch): Promise<Uint8Array>;
23
+ isAllowedUrl?(value: string): boolean;
24
+ wait?(milliseconds: number): Promise<void>;
25
+ }
26
+ export declare function selectRecentTelemetryMatches(matches: NormalizedMatch[], requestedLimit?: number): NormalizedMatch[];
27
+ /** Backfill is capped at the newest source matches (see the limit constant) with no older fallback. */
28
+ export declare function backfillRecentRawTelemetry(matches: NormalizedMatch[], archive: RawTelemetryArchivePort, options: RawTelemetryBackfillOptions): Promise<RawTelemetryBackfillResult>;
@@ -0,0 +1,144 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ /**
4
+ * PUBG's telemetry CDN only serves a match for roughly two weeks, so the useful window is whatever the CDN still
5
+ * answers for. The cap bounds one run; expired assets (HTTP 403/404/410) are counted as missing, not retried.
6
+ */
7
+ export const HISTORICAL_TELEMETRY_BACKFILL_LIMIT = 500;
8
+ export function isExpiredTelemetryAsset(error) {
9
+ return error instanceof Error && /^pubg_telemetry_http_(403|404|410)$/u.test(error.message);
10
+ }
11
+ async function canonicalFuturePath(value) {
12
+ let current = resolve(value);
13
+ const missingTail = [];
14
+ while (true) {
15
+ try {
16
+ const existing = await realpath(current);
17
+ return resolve(existing, ...missingTail.reverse());
18
+ }
19
+ catch (error) {
20
+ if (error.code !== 'ENOENT')
21
+ throw error;
22
+ const parent = dirname(current);
23
+ if (parent === current)
24
+ throw error;
25
+ missingTail.push(basename(current));
26
+ current = parent;
27
+ }
28
+ }
29
+ }
30
+ /** Resolve symlinks in existing parents and keep private captures outside Git. */
31
+ export async function assertArchiveOutsideRepository(archiveDirectory, repositoryRoot) {
32
+ const root = await realpath(repositoryRoot);
33
+ const archive = await canonicalFuturePath(archiveDirectory);
34
+ const relativePath = relative(root, archive);
35
+ const isInsideRepository = relativePath === '' ||
36
+ (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));
37
+ if (isInsideRepository)
38
+ throw new Error('telemetry_archive_must_be_outside_repository');
39
+ return archive;
40
+ }
41
+ export function selectRecentTelemetryMatches(matches, requestedLimit = HISTORICAL_TELEMETRY_BACKFILL_LIMIT) {
42
+ const limit = Math.min(HISTORICAL_TELEMETRY_BACKFILL_LIMIT, Math.max(0, Math.trunc(requestedLimit)));
43
+ return [...matches]
44
+ .sort((left, right) => right.timestamp - left.timestamp || right.matchId.localeCompare(left.matchId))
45
+ .slice(0, limit);
46
+ }
47
+ function telemetryJsonIsReadable(raw) {
48
+ try {
49
+ const payload = JSON.parse(Buffer.from(raw).toString('utf8'));
50
+ if (Array.isArray(payload))
51
+ return true;
52
+ if (!payload || typeof payload !== 'object')
53
+ return false;
54
+ const object = payload;
55
+ return (Array.isArray(object.events) || Array.isArray(object.data) || Array.isArray(object.telemetry));
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function mapWithConcurrency(items, concurrency, callback) {
62
+ let nextIndex = 0;
63
+ const worker = async () => {
64
+ while (true) {
65
+ const index = nextIndex++;
66
+ if (index >= items.length)
67
+ return;
68
+ await callback(items[index]);
69
+ }
70
+ };
71
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
72
+ }
73
+ /** Backfill is capped at the newest source matches (see the limit constant) with no older fallback. */
74
+ export async function backfillRecentRawTelemetry(matches, archive, options) {
75
+ const limit = Math.min(HISTORICAL_TELEMETRY_BACKFILL_LIMIT, Math.max(0, Math.trunc(options.limit ?? HISTORICAL_TELEMETRY_BACKFILL_LIMIT)));
76
+ const retries = Math.min(5, Math.max(1, Math.trunc(options.retries ?? 3)));
77
+ const concurrency = Math.min(4, Math.max(1, Math.trunc(options.concurrency ?? 2)));
78
+ const wait = options.wait ??
79
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
80
+ const selected = selectRecentTelemetryMatches(matches, limit);
81
+ const counts = {
82
+ selectedMatchCount: selected.length,
83
+ eligibleMatchCount: 0,
84
+ alreadyArchivedCount: 0,
85
+ archivedCount: 0,
86
+ missingAssetCount: 0,
87
+ failedCount: 0,
88
+ };
89
+ const eligible = [];
90
+ for (const match of selected) {
91
+ const url = match.telemetryUrl;
92
+ if (!url || (options.isAllowedUrl && !options.isAllowedUrl(url))) {
93
+ counts.missingAssetCount += 1;
94
+ continue;
95
+ }
96
+ eligible.push(match);
97
+ }
98
+ counts.eligibleMatchCount = eligible.length;
99
+ await mapWithConcurrency(eligible, concurrency, async (match) => {
100
+ let forceRefresh = false;
101
+ try {
102
+ const cached = await archive.read(match.matchId);
103
+ if (cached) {
104
+ if (telemetryJsonIsReadable(cached))
105
+ counts.alreadyArchivedCount += 1;
106
+ else
107
+ forceRefresh = true;
108
+ if (!forceRefresh)
109
+ return;
110
+ }
111
+ }
112
+ catch {
113
+ counts.failedCount += 1;
114
+ return;
115
+ }
116
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
117
+ try {
118
+ const fetchRaw = () => options.fetchRaw(match);
119
+ const raw = forceRefresh
120
+ ? await archive.refresh(match.matchId, fetchRaw)
121
+ : await archive.getOrFetch(match.matchId, fetchRaw);
122
+ if (!telemetryJsonIsReadable(raw)) {
123
+ forceRefresh = true;
124
+ throw new Error('telemetry_response_json_invalid');
125
+ }
126
+ counts.archivedCount += 1;
127
+ return;
128
+ }
129
+ catch (error) {
130
+ if (isExpiredTelemetryAsset(error)) {
131
+ counts.missingAssetCount += 1;
132
+ return;
133
+ }
134
+ if (attempt === retries) {
135
+ counts.failedCount += 1;
136
+ return;
137
+ }
138
+ await wait(Math.min(500 * 2 ** (attempt - 1), 4000));
139
+ }
140
+ }
141
+ });
142
+ return counts;
143
+ }
144
+ //# sourceMappingURL=raw-telemetry-backfill.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"raw-telemetry-backfill.js","sourceRoot":"","sources":["../../src/raw-telemetry-backfill.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,89 @@
1
+ import type { NormalizedMatch } from '@rine-kit/pubg-domain';
2
+ import type { ResearchIntent } from './intent.js';
3
+ export interface ResearchSquad {
4
+ id: string;
5
+ label: string;
6
+ memberIds: string[];
7
+ members: Array<{
8
+ id: string;
9
+ name: string;
10
+ }>;
11
+ matchCount: number;
12
+ }
13
+ export interface ResearchEvidence {
14
+ id: string;
15
+ matchId: string;
16
+ occurredAt: string | null;
17
+ mapName: string;
18
+ source: 'match_store' | 'telemetry_feature';
19
+ field: string;
20
+ value: string;
21
+ description: string;
22
+ }
23
+ export interface ResearchPlayerMetric {
24
+ id: string;
25
+ name: string;
26
+ matches: number;
27
+ kills: number;
28
+ damage: number;
29
+ knocks: number;
30
+ revives: number;
31
+ averageDamage: number;
32
+ averageSurvivalTime: number;
33
+ wins: number;
34
+ }
35
+ export interface ResearchBreakdown {
36
+ label: string;
37
+ matches: number;
38
+ rankedMatches: number;
39
+ total: number;
40
+ average: number;
41
+ wins: number;
42
+ top10: number;
43
+ }
44
+ export interface ResearchReport {
45
+ status: 'ok' | 'partial' | 'empty';
46
+ action: ResearchIntent['action'];
47
+ metric: ResearchIntent['metric'];
48
+ scope: {
49
+ squadLabel: string;
50
+ from: string | null;
51
+ to: string | null;
52
+ selectedMatches: number;
53
+ availableMatches: number;
54
+ };
55
+ summary: {
56
+ totalKills: number;
57
+ totalDamage: number;
58
+ averageKills: number;
59
+ averageDamage: number;
60
+ averageKnocks: number;
61
+ averageRevives: number;
62
+ wins: number;
63
+ top10: number;
64
+ rankedMatches: number;
65
+ winRate: number | null;
66
+ top10Rate: number | null;
67
+ };
68
+ telemetry: {
69
+ currentCacheMatches: number;
70
+ missingCurrentCacheMatches: number;
71
+ coverageRate: number | null;
72
+ };
73
+ players: ResearchPlayerMetric[];
74
+ breakdown: ResearchBreakdown[];
75
+ evidence: ResearchEvidence[];
76
+ evidenceCount: number;
77
+ answer: string;
78
+ }
79
+ export interface BuildResearchReportInput {
80
+ matches: readonly NormalizedMatch[];
81
+ squad: Pick<ResearchSquad, 'label' | 'memberIds'>;
82
+ intent: ResearchIntent;
83
+ fromMs?: number;
84
+ toMs?: number;
85
+ telemetryMatchIds?: ReadonlySet<string>;
86
+ selectedMatchId?: string;
87
+ }
88
+ export declare function buildResearchReport(input: BuildResearchReportInput): ResearchReport;
89
+ export declare function buildResearchSquads(matches: readonly NormalizedMatch[]): ResearchSquad[];
@@ -0,0 +1,376 @@
1
+ import { createHash } from 'node:crypto';
2
+ function dateOf(match) {
3
+ const value = match.createdAt ??
4
+ (Number.isFinite(match.timestamp) ? new Date(match.timestamp).toISOString() : null);
5
+ return value && Number.isFinite(Date.parse(value)) ? value : null;
6
+ }
7
+ function membersOf(match, ids) {
8
+ if (ids.size === 0)
9
+ return match.players;
10
+ return match.players.filter((player) => ids.has(player.accountId));
11
+ }
12
+ function placementOf(players) {
13
+ const ranks = players
14
+ .map((player) => player.rank)
15
+ .filter((rank) => rank !== null && rank > 0);
16
+ const distinct = new Set(ranks);
17
+ return distinct.size === 1 ? ranks[0] : null;
18
+ }
19
+ function metricFor(matchPlayers, metric) {
20
+ switch (metric) {
21
+ case 'kills':
22
+ return matchPlayers.reduce((sum, player) => sum + player.kills, 0);
23
+ case 'knocks':
24
+ return matchPlayers.reduce((sum, player) => sum + player.dbnos, 0);
25
+ case 'revives':
26
+ return matchPlayers.reduce((sum, player) => sum + player.revives, 0);
27
+ case 'wins': {
28
+ const placement = placementOf(matchPlayers);
29
+ return placement === null ? null : placement === 1 ? 1 : 0;
30
+ }
31
+ case 'top10': {
32
+ const placement = placementOf(matchPlayers);
33
+ return placement === null ? null : placement <= 10 ? 1 : 0;
34
+ }
35
+ case 'matches':
36
+ return 1;
37
+ case 'survival_time':
38
+ return matchPlayers.length
39
+ ? matchPlayers.reduce((sum, player) => sum + player.survivalTime, 0) / matchPlayers.length
40
+ : 0;
41
+ case 'damage':
42
+ case null:
43
+ return matchPlayers.reduce((sum, player) => sum + player.damage, 0);
44
+ }
45
+ }
46
+ function stableWeek(timestamp) {
47
+ const monday = new Date(timestamp);
48
+ monday.setUTCHours(0, 0, 0, 0);
49
+ monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7));
50
+ return monday.toISOString().slice(0, 10);
51
+ }
52
+ function metricLabel(metric) {
53
+ return {
54
+ damage: '伤害',
55
+ kills: '击杀',
56
+ knocks: '倒地',
57
+ revives: '救援',
58
+ wins: '吃鸡',
59
+ top10: '前十',
60
+ matches: '比赛数',
61
+ survival_time: '平均生存时长',
62
+ }[metric ?? 'damage'];
63
+ }
64
+ function fmt(value) {
65
+ return Math.round(value).toLocaleString('zh-CN');
66
+ }
67
+ function breakdownValue(item, metric) {
68
+ if (metric === 'wins' || metric === 'top10')
69
+ return item.rankedMatches ? `${(item.average * 100).toFixed(1)}%` : '排名未知';
70
+ if (metric === 'matches')
71
+ return `${item.matches} 场`;
72
+ return fmt(item.average);
73
+ }
74
+ function buildAnswer(report) {
75
+ if (!report.scope.selectedMatches)
76
+ return '当前小队和时间范围内没有可分析的历史比赛。可以扩大时间范围或选择其他小队。';
77
+ if (report.action === 'telemetry_coverage') {
78
+ const rate = report.telemetry.coverageRate === null
79
+ ? '未知'
80
+ : `${(report.telemetry.coverageRate * 100).toFixed(1)}%`;
81
+ return `当前范围有 ${report.scope.selectedMatches} 场比赛,其中 ${report.telemetry.currentCacheMatches} 场有 parser/feature v6 缓存,覆盖率 ${rate}。没有缓存的比赛仍可查看 Match Store 基础战绩;本地研究不会重新下载 Telemetry。`;
82
+ }
83
+ if (report.action === 'trend') {
84
+ if (!report.breakdown.length)
85
+ return `当前范围有 ${report.scope.selectedMatches} 场,但没有足够数据生成${metricLabel(report.metric)}趋势。`;
86
+ const points = report.breakdown
87
+ .map((item) => `${item.label} ${breakdownValue(item, report.metric)}`)
88
+ .join(';');
89
+ return `${metricLabel(report.metric)}逐周趋势(每场平均):${points}。总计 ${report.scope.selectedMatches} 场,证据可按日期和地图逐场查看。`;
90
+ }
91
+ if (report.action === 'player_comparison') {
92
+ const ordered = [...report.players].sort((left, right) => {
93
+ const metric = report.metric ?? 'damage';
94
+ const value = (player) => metric === 'matches'
95
+ ? player.matches
96
+ : metric === 'wins'
97
+ ? player.wins
98
+ : metric === 'knocks'
99
+ ? player.knocks
100
+ : metric === 'revives'
101
+ ? player.revives
102
+ : metric === 'kills'
103
+ ? player.kills
104
+ : metric === 'survival_time'
105
+ ? player.averageSurvivalTime
106
+ : player.damage;
107
+ return value(right) - value(left);
108
+ });
109
+ const leaders = ordered
110
+ .slice(0, 4)
111
+ .map((player) => `${player.name} ${fmt(report.metric === 'matches' ? player.matches : report.metric === 'wins' ? player.wins : report.metric === 'knocks' ? player.knocks : report.metric === 'revives' ? player.revives : report.metric === 'kills' ? player.kills : report.metric === 'survival_time' ? player.averageSurvivalTime : player.damage)}`)
112
+ .join(';');
113
+ return `按${metricLabel(report.metric)}比较队员(${report.scope.selectedMatches} 场):${leaders}。统计只累计所选范围中可见的队员战绩。`;
114
+ }
115
+ if (report.action === 'map_breakdown') {
116
+ const maps = report.breakdown
117
+ .map((item) => `${item.label} ${breakdownValue(item, report.metric)}${report.metric === 'matches' ? '' : ' / 场'}(${item.matches} 场,${item.rankedMatches} 场有排名)`)
118
+ .join(';');
119
+ return `按地图拆分${metricLabel(report.metric)}:${maps || '暂无可用地图分组'}。地图样本数不同,比较时请同时看每组场数。`;
120
+ }
121
+ if (report.action === 'unsupported')
122
+ return '我可以研究小队比赛统计、队员对比、地图表现、时间趋势和 Telemetry 缓存覆盖。请把问题限定在这些 PUBG 数据范围内。';
123
+ const winRate = report.summary.winRate === null ? '未知' : `${(report.summary.winRate * 100).toFixed(1)}%`;
124
+ const top10Rate = report.summary.top10Rate === null ? '未知' : `${(report.summary.top10Rate * 100).toFixed(1)}%`;
125
+ const rankNote = report.summary.rankedMatches < report.scope.selectedMatches
126
+ ? `排名仅 ${report.summary.rankedMatches}/${report.scope.selectedMatches} 场可用,胜率与前十率按有排名场数计算。`
127
+ : '';
128
+ return `当前范围 ${report.scope.selectedMatches} 场:累计 ${fmt(report.summary.totalKills)} 杀,平均每场 ${fmt(report.summary.averageDamage)} 伤害、${fmt(report.summary.averageKnocks)} 倒地、${fmt(report.summary.averageRevives)} 次救援;吃鸡 ${report.summary.wins} 场(${winRate}),前十 ${report.summary.top10} 场(${top10Rate})。${rankNote}事件级 Telemetry 缓存覆盖 ${report.telemetry.currentCacheMatches}/${report.scope.selectedMatches} 场。`;
129
+ }
130
+ export function buildResearchReport(input) {
131
+ const squadIds = new Set(input.squad.memberIds);
132
+ const available = input.matches
133
+ .filter((match) => {
134
+ const time = Number.isFinite(match.timestamp)
135
+ ? match.timestamp
136
+ : Date.parse(match.createdAt ?? '');
137
+ if (!Number.isFinite(time))
138
+ return false;
139
+ if (input.fromMs !== undefined && time < input.fromMs)
140
+ return false;
141
+ if (input.toMs !== undefined && time >= input.toMs)
142
+ return false;
143
+ if (input.selectedMatchId && match.matchId !== input.selectedMatchId)
144
+ return false;
145
+ return squadIds.size === 0 || match.players.some((player) => squadIds.has(player.accountId));
146
+ })
147
+ .sort((a, b) => a.timestamp - b.timestamp || a.matchId.localeCompare(b.matchId));
148
+ const selected = available.flatMap((match) => {
149
+ const players = membersOf(match, squadIds);
150
+ return players.length || squadIds.size === 0
151
+ ? [{ match, players, placement: placementOf(players) }]
152
+ : [];
153
+ });
154
+ const telemetryCount = selected.filter(({ match }) => input.telemetryMatchIds?.has(match.matchId) ?? false).length;
155
+ const totalDamage = selected.reduce((sum, item) => sum + item.players.reduce((value, player) => value + player.damage, 0), 0);
156
+ const totalKills = selected.reduce((sum, item) => sum + item.players.reduce((value, player) => value + player.kills, 0), 0);
157
+ const totalKnocks = selected.reduce((sum, item) => sum + item.players.reduce((value, player) => value + player.dbnos, 0), 0);
158
+ const totalRevives = selected.reduce((sum, item) => sum + item.players.reduce((value, player) => value + player.revives, 0), 0);
159
+ const wins = selected.filter((item) => item.placement === 1).length;
160
+ const top10 = selected.filter((item) => item.placement !== null && item.placement <= 10).length;
161
+ const rankedMatches = selected.filter((item) => item.placement !== null).length;
162
+ const count = selected.length;
163
+ const playerMap = new Map();
164
+ for (const { players } of selected) {
165
+ for (const player of players) {
166
+ const current = playerMap.get(player.accountId) ?? {
167
+ player,
168
+ matches: 0,
169
+ kills: 0,
170
+ damage: 0,
171
+ knocks: 0,
172
+ revives: 0,
173
+ survivalTime: 0,
174
+ wins: 0,
175
+ };
176
+ current.matches += 1;
177
+ current.kills += player.kills;
178
+ current.damage += player.damage;
179
+ current.knocks += player.dbnos;
180
+ current.revives += player.revives;
181
+ current.survivalTime += player.survivalTime;
182
+ current.wins += placementOf(players) === 1 && player.rank === 1 ? 1 : 0;
183
+ playerMap.set(player.accountId, current);
184
+ }
185
+ }
186
+ const players = [...playerMap.entries()]
187
+ .map(([id, value]) => ({
188
+ id: createHash('sha256').update(id).digest('hex').slice(0, 12),
189
+ name: value.player.displayName || value.player.playerName,
190
+ matches: value.matches,
191
+ kills: value.kills,
192
+ damage: value.damage,
193
+ knocks: value.knocks,
194
+ revives: value.revives,
195
+ averageDamage: value.matches ? value.damage / value.matches : 0,
196
+ averageSurvivalTime: value.matches ? value.survivalTime / value.matches : 0,
197
+ wins: value.wins,
198
+ }))
199
+ .sort((a, b) => b.damage - a.damage || b.kills - a.kills || a.name.localeCompare(b.name));
200
+ const totalFor = (items, metric) => items.reduce((sum, item) => sum + (metricFor(item.players, metric) ?? 0), 0);
201
+ const grouped = new Map();
202
+ if (input.intent.action === 'trend') {
203
+ for (const item of selected) {
204
+ const key = stableWeek(item.match.timestamp);
205
+ grouped.set(key, [...(grouped.get(key) ?? []), item]);
206
+ }
207
+ }
208
+ else if (input.intent.action === 'map_breakdown') {
209
+ for (const item of selected) {
210
+ const key = item.match.mapName || '未知地图';
211
+ grouped.set(key, [...(grouped.get(key) ?? []), item]);
212
+ }
213
+ }
214
+ const breakdown = [...grouped.entries()]
215
+ .map(([label, items]) => {
216
+ const total = totalFor(items, input.intent.metric);
217
+ const groupRankedMatches = items.filter((item) => item.placement !== null).length;
218
+ const groupWins = items.filter((item) => item.placement === 1).length;
219
+ const groupTop10 = items.filter((item) => item.placement !== null && item.placement <= 10).length;
220
+ return {
221
+ label,
222
+ matches: items.length,
223
+ rankedMatches: groupRankedMatches,
224
+ total,
225
+ average: input.intent.metric === 'wins'
226
+ ? groupRankedMatches
227
+ ? groupWins / groupRankedMatches
228
+ : 0
229
+ : input.intent.metric === 'top10'
230
+ ? groupRankedMatches
231
+ ? groupTop10 / groupRankedMatches
232
+ : 0
233
+ : items.length
234
+ ? total / items.length
235
+ : 0,
236
+ wins: groupWins,
237
+ top10: groupTop10,
238
+ };
239
+ })
240
+ .sort((a, b) => a.label.localeCompare(b.label));
241
+ const evidence = selected
242
+ .slice(-40)
243
+ .reverse()
244
+ .map(({ match, players: matchPlayers }) => {
245
+ const metric = input.intent.metric ?? 'damage';
246
+ const value = metricFor(matchPlayers, metric);
247
+ const at = dateOf(match);
248
+ const matchDamage = matchPlayers.reduce((sum, player) => sum + player.damage, 0);
249
+ const matchKills = matchPlayers.reduce((sum, player) => sum + player.kills, 0);
250
+ const matchPlacement = placementOf(matchPlayers);
251
+ return {
252
+ id: `match-${match.matchId}-${metric}`,
253
+ matchId: match.matchId,
254
+ occurredAt: at,
255
+ mapName: match.mapName || '未知地图',
256
+ source: 'match_store',
257
+ field: metric,
258
+ value: value === null ? '排名未知' : `${fmt(value)} ${metricLabel(metric)}`,
259
+ description: `${matchPlacement ? `排名第 ${matchPlacement} · ` : ''}${fmt(matchKills)} 杀 · ${fmt(matchDamage)} 伤害`,
260
+ };
261
+ });
262
+ const from = input.fromMs === undefined ? null : new Date(input.fromMs).toISOString();
263
+ const to = input.toMs === undefined ? null : new Date(input.toMs - 1).toISOString();
264
+ const base = {
265
+ status: count === 0 ? 'empty' : telemetryCount < count ? 'partial' : 'ok',
266
+ action: input.intent.action,
267
+ metric: input.intent.metric,
268
+ scope: {
269
+ squadLabel: input.squad.label,
270
+ from,
271
+ to,
272
+ selectedMatches: count,
273
+ availableMatches: input.matches.length,
274
+ },
275
+ summary: {
276
+ totalKills,
277
+ totalDamage,
278
+ averageKills: count ? totalKills / count : 0,
279
+ averageDamage: count ? totalDamage / count : 0,
280
+ averageKnocks: count ? totalKnocks / count : 0,
281
+ averageRevives: count ? totalRevives / count : 0,
282
+ wins,
283
+ top10,
284
+ rankedMatches,
285
+ winRate: rankedMatches ? wins / rankedMatches : null,
286
+ top10Rate: rankedMatches ? top10 / rankedMatches : null,
287
+ },
288
+ telemetry: {
289
+ currentCacheMatches: telemetryCount,
290
+ missingCurrentCacheMatches: count - telemetryCount,
291
+ coverageRate: count ? telemetryCount / count : null,
292
+ },
293
+ players,
294
+ breakdown,
295
+ evidence,
296
+ evidenceCount: selected.length,
297
+ };
298
+ return { ...base, answer: buildAnswer(base) };
299
+ }
300
+ export function buildResearchSquads(matches) {
301
+ const pairCounts = new Map();
302
+ const names = new Map();
303
+ const appearances = new Map();
304
+ for (const match of matches) {
305
+ const ids = [...new Set(match.players.map((player) => player.accountId))].sort();
306
+ for (const player of match.players) {
307
+ appearances.set(player.accountId, (appearances.get(player.accountId) ?? 0) + 1);
308
+ const options = names.get(player.accountId) ?? new Map();
309
+ const label = player.displayName || player.playerName || '队员';
310
+ options.set(label, (options.get(label) ?? 0) + 1);
311
+ names.set(player.accountId, options);
312
+ }
313
+ for (let left = 0; left < ids.length; left += 1) {
314
+ for (let right = left + 1; right < ids.length; right += 1) {
315
+ const key = `${ids[left]}\u0000${ids[right]}`;
316
+ pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
317
+ }
318
+ }
319
+ }
320
+ const ids = [...appearances.keys()].sort((a, b) => (appearances.get(b) ?? 0) - (appearances.get(a) ?? 0) || a.localeCompare(b));
321
+ const adjacency = new Map(ids.map((id) => [id, new Set()]));
322
+ for (const [key, count] of pairCounts) {
323
+ if (count < 2)
324
+ continue;
325
+ const [left, right] = key.split('\u0000');
326
+ adjacency.get(left)?.add(right);
327
+ adjacency.get(right)?.add(left);
328
+ }
329
+ const groups = [];
330
+ const visited = new Set();
331
+ for (const id of ids) {
332
+ if (visited.has(id))
333
+ continue;
334
+ const pending = [id];
335
+ const group = [];
336
+ visited.add(id);
337
+ while (pending.length) {
338
+ const current = pending.pop();
339
+ group.push(current);
340
+ for (const next of adjacency.get(current) ?? []) {
341
+ if (!visited.has(next)) {
342
+ visited.add(next);
343
+ pending.push(next);
344
+ }
345
+ }
346
+ }
347
+ if (group.length > 1)
348
+ groups.push(group.sort());
349
+ }
350
+ const profiles = groups
351
+ .map((memberIds, index) => {
352
+ const memberSet = new Set(memberIds);
353
+ const matchCount = matches.filter((match) => match.players.some((player) => memberSet.has(player.accountId))).length;
354
+ const id = createHash('sha256').update(memberIds.join('\u0000')).digest('hex').slice(0, 12);
355
+ const members = memberIds.map((memberId) => {
356
+ const options = names.get(memberId);
357
+ const name = [...(options?.entries() ?? [])].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? '队员';
358
+ return { id: createHash('sha256').update(memberId).digest('hex').slice(0, 12), name };
359
+ });
360
+ return {
361
+ id,
362
+ label: `小队 ${String.fromCharCode(65 + index)}`,
363
+ memberIds,
364
+ members,
365
+ matchCount,
366
+ };
367
+ })
368
+ .sort((a, b) => b.matchCount - a.matchCount ||
369
+ b.memberIds.length - a.memberIds.length ||
370
+ a.id.localeCompare(b.id));
371
+ return profiles.map((profile, index) => ({
372
+ ...profile,
373
+ label: `小队 ${String.fromCharCode(65 + index)}`,
374
+ }));
375
+ }
376
+ //# sourceMappingURL=research.js.map