@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.
- package/LICENSE +21 -0
- package/dist/src/analytics.d.ts +176 -0
- package/dist/src/analytics.js +431 -0
- package/dist/src/analytics.js.map +1 -0
- package/dist/src/assistant.d.ts +87 -0
- package/dist/src/assistant.js +310 -0
- package/dist/src/assistant.js.map +1 -0
- package/dist/src/battle.d.ts +280 -0
- package/dist/src/battle.js +1249 -0
- package/dist/src/battle.js.map +1 -0
- package/dist/src/combat-long.d.ts +42 -0
- package/dist/src/combat-long.js +146 -0
- package/dist/src/combat-long.js.map +1 -0
- package/dist/src/combat-quality.d.ts +101 -0
- package/dist/src/combat-quality.js +253 -0
- package/dist/src/combat-quality.js.map +1 -0
- package/dist/src/evaluation-fixtures.d.ts +13 -0
- package/dist/src/evaluation-fixtures.js +144 -0
- package/dist/src/evaluation-fixtures.js.map +1 -0
- package/dist/src/evaluation-replay.d.ts +5 -0
- package/dist/src/evaluation-replay.js +107 -0
- package/dist/src/evaluation-replay.js.map +1 -0
- package/dist/src/evaluation.d.ts +140 -0
- package/dist/src/evaluation.js +180 -0
- package/dist/src/evaluation.js.map +1 -0
- package/dist/src/experiment-store.d.ts +56 -0
- package/dist/src/experiment-store.js +159 -0
- package/dist/src/experiment-store.js.map +1 -0
- package/dist/src/field.d.ts +103 -0
- package/dist/src/field.js +298 -0
- package/dist/src/field.js.map +1 -0
- package/dist/src/fights.d.ts +89 -0
- package/dist/src/fights.js +238 -0
- package/dist/src/fights.js.map +1 -0
- package/dist/src/highlights.d.ts +44 -0
- package/dist/src/highlights.js +153 -0
- package/dist/src/highlights.js.map +1 -0
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/insights.d.ts +44 -0
- package/dist/src/insights.js +343 -0
- package/dist/src/insights.js.map +1 -0
- package/dist/src/intent.d.ts +40 -0
- package/dist/src/intent.js +119 -0
- package/dist/src/intent.js.map +1 -0
- package/dist/src/landing.d.ts +74 -0
- package/dist/src/landing.js +195 -0
- package/dist/src/landing.js.map +1 -0
- package/dist/src/lobby.d.ts +48 -0
- package/dist/src/lobby.js +209 -0
- package/dist/src/lobby.js.map +1 -0
- package/dist/src/match-insight.d.ts +165 -0
- package/dist/src/match-insight.js +542 -0
- package/dist/src/match-insight.js.map +1 -0
- package/dist/src/match-relative.d.ts +73 -0
- package/dist/src/match-relative.js +145 -0
- package/dist/src/match-relative.js.map +1 -0
- package/dist/src/match-story.d.ts +155 -0
- package/dist/src/match-story.js +636 -0
- package/dist/src/match-story.js.map +1 -0
- package/dist/src/profiles.d.ts +41 -0
- package/dist/src/profiles.js +136 -0
- package/dist/src/profiles.js.map +1 -0
- package/dist/src/raw-telemetry-archive.d.ts +29 -0
- package/dist/src/raw-telemetry-archive.js +340 -0
- package/dist/src/raw-telemetry-archive.js.map +1 -0
- package/dist/src/raw-telemetry-backfill.d.ts +28 -0
- package/dist/src/raw-telemetry-backfill.js +144 -0
- package/dist/src/raw-telemetry-backfill.js.map +1 -0
- package/dist/src/research.d.ts +89 -0
- package/dist/src/research.js +376 -0
- package/dist/src/research.js.map +1 -0
- package/dist/src/rhythm.d.ts +104 -0
- package/dist/src/rhythm.js +260 -0
- package/dist/src/rhythm.js.map +1 -0
- package/dist/src/streaks.d.ts +117 -0
- package/dist/src/streaks.js +367 -0
- package/dist/src/streaks.js.map +1 -0
- package/dist/src/style.d.ts +304 -0
- package/dist/src/style.js +1461 -0
- package/dist/src/style.js.map +1 -0
- package/dist/src/tactics.d.ts +88 -0
- package/dist/src/tactics.js +1221 -0
- package/dist/src/tactics.js.map +1 -0
- package/package.json +49 -3
- package/README.md +0 -3
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { battlePlayerRef, battleTeamRef, } from './battle.js';
|
|
3
|
+
export const TACTICAL_ANALYSIS_SCHEMA_VERSION = 2;
|
|
4
|
+
export const TACTICAL_RULE_VERSION = 'tactical-social-rules-v2';
|
|
5
|
+
export const TACTICAL_FOLLOW_UP_WINDOW_SECONDS = 30;
|
|
6
|
+
export const TACTICAL_RESPONSE_WINDOW_SECONDS = 30;
|
|
7
|
+
export const TACTICAL_POSITION_BUCKET_SECONDS = 15;
|
|
8
|
+
export const TACTICAL_ISOLATION_DISTANCE_UNITS = 1_000;
|
|
9
|
+
export const TACTICAL_CIRCLE_WINDOW_SECONDS = 60;
|
|
10
|
+
export const TACTICAL_MOVEMENT_MAX_GAP_SECONDS = 30;
|
|
11
|
+
export const TACTICAL_PAIR_MIN_SHARED_MATCHES = 3;
|
|
12
|
+
export const TACTICAL_MAX_EVIDENCE_REFS = 8;
|
|
13
|
+
function digest(value, length = 20) {
|
|
14
|
+
return createHash('sha256').update(value).digest('hex').slice(0, length);
|
|
15
|
+
}
|
|
16
|
+
/** Squad-scoped pseudonymous ref for comparing the same roster across match files. */
|
|
17
|
+
export function tacticalPlayerRef(accountId) {
|
|
18
|
+
return `squad-player-${digest(`${TACTICAL_RULE_VERSION}\u001f${accountId}`, 16)}`;
|
|
19
|
+
}
|
|
20
|
+
function uniqueEvents(events) {
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
return events.filter((event) => {
|
|
23
|
+
if (seen.has(event.sourceEventId))
|
|
24
|
+
return false;
|
|
25
|
+
seen.add(event.sourceEventId);
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function eventRef(matchId, event, kind) {
|
|
30
|
+
if (!event)
|
|
31
|
+
return null;
|
|
32
|
+
return {
|
|
33
|
+
matchId,
|
|
34
|
+
eventId: event.sourceEventId,
|
|
35
|
+
timeSeconds: event.timeSeconds,
|
|
36
|
+
kind: kind ?? event.type.toLowerCase(),
|
|
37
|
+
actorRef: event.actorId ? tacticalPlayerRef(event.actorId) : null,
|
|
38
|
+
victimRef: event.victimId ? tacticalPlayerRef(event.victimId) : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function matchRef(matchId, kind) {
|
|
42
|
+
return { matchId, eventId: null, timeSeconds: null, kind };
|
|
43
|
+
}
|
|
44
|
+
function uniqueRefs(refs, limit = TACTICAL_MAX_EVIDENCE_REFS) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
return refs
|
|
47
|
+
.flatMap((ref) => {
|
|
48
|
+
if (!ref)
|
|
49
|
+
return [];
|
|
50
|
+
const key = `${ref.matchId}\u001f${ref.eventId ?? ''}\u001f${ref.kind}`;
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
return [];
|
|
53
|
+
seen.add(key);
|
|
54
|
+
return [ref];
|
|
55
|
+
})
|
|
56
|
+
.sort((left, right) => (left.timeSeconds ?? Number.POSITIVE_INFINITY) -
|
|
57
|
+
(right.timeSeconds ?? Number.POSITIVE_INFINITY) ||
|
|
58
|
+
left.matchId.localeCompare(right.matchId) ||
|
|
59
|
+
(left.eventId ?? '').localeCompare(right.eventId ?? '') ||
|
|
60
|
+
left.kind.localeCompare(right.kind))
|
|
61
|
+
.slice(0, limit);
|
|
62
|
+
}
|
|
63
|
+
function trackedEpisode(episode) {
|
|
64
|
+
return episode.teams.some((team) => team.tracked && team.teamRef !== null);
|
|
65
|
+
}
|
|
66
|
+
function episodeContactPlayers(episode) {
|
|
67
|
+
const players = new Set();
|
|
68
|
+
for (const event of episode.timeline) {
|
|
69
|
+
if (event.type !== 'DAMAGE' && event.type !== 'KNOCK' && event.type !== 'KILL')
|
|
70
|
+
continue;
|
|
71
|
+
if (event.actorRef)
|
|
72
|
+
players.add(event.actorRef);
|
|
73
|
+
if (event.victimRef)
|
|
74
|
+
players.add(event.victimRef);
|
|
75
|
+
}
|
|
76
|
+
return players;
|
|
77
|
+
}
|
|
78
|
+
function isContact(event) {
|
|
79
|
+
return event.type === 'DAMAGE' || event.type === 'KNOCK' || event.type === 'KILL';
|
|
80
|
+
}
|
|
81
|
+
function isEffective(event) {
|
|
82
|
+
return event.type === 'DAMAGE'
|
|
83
|
+
? event.hit && event.damage > 0
|
|
84
|
+
: event.type === 'KNOCK' || event.type === 'KILL';
|
|
85
|
+
}
|
|
86
|
+
function gradeFor(sampleSize, denominator, complete) {
|
|
87
|
+
if (denominator === null || denominator === 0 || sampleSize === 0)
|
|
88
|
+
return 'insufficient';
|
|
89
|
+
if (sampleSize >= 10 && complete)
|
|
90
|
+
return 'high';
|
|
91
|
+
if (sampleSize >= 4 && complete)
|
|
92
|
+
return 'moderate';
|
|
93
|
+
return 'low';
|
|
94
|
+
}
|
|
95
|
+
function makeCandidate(input) {
|
|
96
|
+
const sampleSize = input.sampleSize ?? input.denominator ?? 0;
|
|
97
|
+
const complete = input.complete ?? true;
|
|
98
|
+
const status = input.statusOverride ??
|
|
99
|
+
(input.denominator === null
|
|
100
|
+
? 'insufficient_data'
|
|
101
|
+
: !complete
|
|
102
|
+
? 'incomplete'
|
|
103
|
+
: input.denominator === 0
|
|
104
|
+
? 'not_observed'
|
|
105
|
+
: 'observed');
|
|
106
|
+
const reasons = [...(input.reasons ?? [])];
|
|
107
|
+
if (input.denominator === null && !reasons.length)
|
|
108
|
+
reasons.push('缺少可识别的分母数据;未知不按 0 计。');
|
|
109
|
+
else if (input.denominator === 0 && !reasons.length)
|
|
110
|
+
reasons.push('当前快照中没有可评估的机会样本。');
|
|
111
|
+
else if (!complete && !reasons.length)
|
|
112
|
+
reasons.push('存在缺失/未知字段,结果只覆盖可识别子集。');
|
|
113
|
+
const grade = gradeFor(sampleSize, input.denominator, complete);
|
|
114
|
+
if (grade === 'high')
|
|
115
|
+
reasons.push('样本量至少 10 且规则所需字段完整。');
|
|
116
|
+
else if (grade === 'moderate')
|
|
117
|
+
reasons.push('样本量为 4–9 且规则所需字段完整。');
|
|
118
|
+
else if (grade === 'low')
|
|
119
|
+
reasons.push('样本量为 1–3,或规则所需字段不完整。');
|
|
120
|
+
else
|
|
121
|
+
reasons.push('没有可评级的有效机会样本。');
|
|
122
|
+
const pairRefs = input.pairRefs;
|
|
123
|
+
const evidenceKey = input.support
|
|
124
|
+
.filter((ref) => ref !== null)
|
|
125
|
+
.map((ref) => `${ref.matchId}\u001f${ref.eventId ?? ''}\u001f${ref.kind}`)
|
|
126
|
+
.sort()
|
|
127
|
+
.join('\u001e');
|
|
128
|
+
return {
|
|
129
|
+
id: `insight-${digest(`${TACTICAL_ANALYSIS_SCHEMA_VERSION}\u001f${TACTICAL_RULE_VERSION}\u001f${input.matchId}\u001f${input.ruleId}\u001f${pairRefs?.join('\u001f') ?? ''}\u001f${evidenceKey}`)}`,
|
|
130
|
+
ruleId: input.ruleId,
|
|
131
|
+
ruleVersion: TACTICAL_RULE_VERSION,
|
|
132
|
+
category: input.category,
|
|
133
|
+
title: input.title,
|
|
134
|
+
status,
|
|
135
|
+
statement: input.statement,
|
|
136
|
+
metric: {
|
|
137
|
+
numerator: input.numerator,
|
|
138
|
+
denominator: input.denominator,
|
|
139
|
+
sampleSize,
|
|
140
|
+
value: input.value === undefined
|
|
141
|
+
? input.denominator
|
|
142
|
+
? (input.numerator ?? 0) / input.denominator
|
|
143
|
+
: null
|
|
144
|
+
: input.value,
|
|
145
|
+
unit: input.unit ?? 'count',
|
|
146
|
+
},
|
|
147
|
+
supportEvents: uniqueRefs(input.support),
|
|
148
|
+
counterexamples: uniqueRefs(input.counterexamples ?? []),
|
|
149
|
+
quality: { grade, reasons },
|
|
150
|
+
assumptions: input.assumptions ?? [],
|
|
151
|
+
...(pairRefs ? { pairRefs } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function pairId(left, right) {
|
|
155
|
+
const pairRefs = [left, right].sort();
|
|
156
|
+
return { pairRefs, pairRef: `pair-${digest(pairRefs.join('\u001f'), 16)}` };
|
|
157
|
+
}
|
|
158
|
+
function pointsByBucket(matchId, telemetry, trackedPlayerIds) {
|
|
159
|
+
const selectedByPlayerBucket = new Map();
|
|
160
|
+
for (const event of telemetry.events) {
|
|
161
|
+
if (event.type !== 'POSITION' ||
|
|
162
|
+
event.phase !== 'in_match' ||
|
|
163
|
+
event.timeSeconds === null ||
|
|
164
|
+
!event.actorId ||
|
|
165
|
+
!trackedPlayerIds.has(event.actorId) ||
|
|
166
|
+
!Number.isFinite(event.location?.x) ||
|
|
167
|
+
!Number.isFinite(event.location?.y))
|
|
168
|
+
continue;
|
|
169
|
+
const point = {
|
|
170
|
+
event,
|
|
171
|
+
playerRef: battlePlayerRef(matchId, event.actorId),
|
|
172
|
+
teamId: event.actorTeamId,
|
|
173
|
+
bucket: Math.floor(event.timeSeconds / TACTICAL_POSITION_BUCKET_SECONDS),
|
|
174
|
+
x: event.location.x,
|
|
175
|
+
y: event.location.y,
|
|
176
|
+
};
|
|
177
|
+
const key = `${point.bucket}\u001f${point.playerRef}`;
|
|
178
|
+
const previous = selectedByPlayerBucket.get(key);
|
|
179
|
+
if (!previous || event.timeSeconds < previous.event.timeSeconds)
|
|
180
|
+
selectedByPlayerBucket.set(key, point);
|
|
181
|
+
}
|
|
182
|
+
const points = [...selectedByPlayerBucket.values()].sort((left, right) => left.bucket - right.bucket ||
|
|
183
|
+
left.playerRef.localeCompare(right.playerRef) ||
|
|
184
|
+
left.event.sourceEventId.localeCompare(right.event.sourceEventId));
|
|
185
|
+
const buckets = new Map();
|
|
186
|
+
for (const point of points)
|
|
187
|
+
buckets.set(point.bucket, [...(buckets.get(point.bucket) ?? []), point]);
|
|
188
|
+
return { points, buckets };
|
|
189
|
+
}
|
|
190
|
+
function pairDistances(points, matchId) {
|
|
191
|
+
const sorted = [...points].sort((left, right) => left.playerRef.localeCompare(right.playerRef));
|
|
192
|
+
const distances = [];
|
|
193
|
+
for (let leftIndex = 0; leftIndex < sorted.length; leftIndex += 1) {
|
|
194
|
+
for (let rightIndex = leftIndex + 1; rightIndex < sorted.length; rightIndex += 1) {
|
|
195
|
+
const left = sorted[leftIndex];
|
|
196
|
+
const right = sorted[rightIndex];
|
|
197
|
+
if (!left.teamId || left.teamId !== right.teamId)
|
|
198
|
+
continue;
|
|
199
|
+
const distance = Math.hypot(left.x - right.x, left.y - right.y);
|
|
200
|
+
if (!Number.isFinite(distance))
|
|
201
|
+
continue;
|
|
202
|
+
distances.push({
|
|
203
|
+
distance,
|
|
204
|
+
refs: [
|
|
205
|
+
eventRef(matchId, left.event, 'sampled-position'),
|
|
206
|
+
eventRef(matchId, right.event, 'sampled-position'),
|
|
207
|
+
].filter((ref) => ref !== null),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return distances;
|
|
212
|
+
}
|
|
213
|
+
function engagementAnchor(episode, eventById) {
|
|
214
|
+
const ordered = [...episode.timeline].sort((left, right) => left.timeSeconds - right.timeSeconds || left.sourceEventId.localeCompare(right.sourceEventId));
|
|
215
|
+
return (ordered
|
|
216
|
+
.map((item) => eventById.get(item.sourceEventId))
|
|
217
|
+
.find((event) => Boolean(event)) ?? null);
|
|
218
|
+
}
|
|
219
|
+
function analyzeFirstKnocks(matchId, episodes, trackedPlayerRefs, eventById, trackedTeamRefs) {
|
|
220
|
+
const trackedEpisodes = episodes.filter((episode) => trackedEpisode(episode));
|
|
221
|
+
const identifiable = trackedEpisodes.flatMap((episode) => {
|
|
222
|
+
if (!episode.firstKnock)
|
|
223
|
+
return [];
|
|
224
|
+
const source = eventById.get(episode.firstKnock.sourceEventId);
|
|
225
|
+
const actorTeamRef = source?.actorTeamId ? battleTeamRef(matchId, source.actorTeamId) : null;
|
|
226
|
+
const knownAttacker = episode.firstKnock.attackerRef !== null || actorTeamRef !== null;
|
|
227
|
+
if (!knownAttacker)
|
|
228
|
+
return [];
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
episode,
|
|
232
|
+
source,
|
|
233
|
+
isTracked: Boolean((episode.firstKnock.attackerRef &&
|
|
234
|
+
trackedPlayerRefs.has(episode.firstKnock.attackerRef)) ||
|
|
235
|
+
(actorTeamRef && trackedTeamRefs.has(actorTeamRef))),
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
});
|
|
239
|
+
const numerator = identifiable.filter((item) => item.isTracked).length;
|
|
240
|
+
const unmatched = trackedEpisodes.filter((episode) => episode.firstKnock &&
|
|
241
|
+
!episode.firstKnock.attackerRef &&
|
|
242
|
+
!eventById.get(episode.firstKnock.sourceEventId)?.actorTeamId).length;
|
|
243
|
+
return makeCandidate({
|
|
244
|
+
matchId,
|
|
245
|
+
ruleId: 'combat.first-knock-share',
|
|
246
|
+
category: 'combat',
|
|
247
|
+
title: '当前小队首个 knock',
|
|
248
|
+
statement: `在 ${identifiable.length} 个攻击者可识别的当前小队交战中,当前小队先 knock ${numerator} 次。`,
|
|
249
|
+
numerator,
|
|
250
|
+
denominator: identifiable.length,
|
|
251
|
+
support: identifiable
|
|
252
|
+
.filter((item) => item.isTracked)
|
|
253
|
+
.map((item) => eventRef(matchId, item.source, 'first-knock')),
|
|
254
|
+
counterexamples: identifiable
|
|
255
|
+
.filter((item) => !item.isTracked)
|
|
256
|
+
.map((item) => eventRef(matchId, item.source, 'opponent-first-knock')),
|
|
257
|
+
complete: unmatched === 0,
|
|
258
|
+
reasons: unmatched
|
|
259
|
+
? [`另有 ${unmatched} 个首个 knock 缺少可识别攻击者,未计入分母。`]
|
|
260
|
+
: undefined,
|
|
261
|
+
assumptions: [
|
|
262
|
+
'只统计 R1 重建且首个 knock 攻击者可归属到当前小队或对手的交战;无 knock 的交战不在分母。',
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function analyzeBattleDynamics(matchId, battles, events, trackedTeamIds) {
|
|
267
|
+
const trackedRefs = new Set([...trackedTeamIds].map((teamId) => battleTeamRef(matchId, teamId)));
|
|
268
|
+
const relevant = battles.filter((battle) => battle.teamRefs.some((teamRef) => trackedRefs.has(teamRef)));
|
|
269
|
+
const eventById = new Map(events.map((event) => [event.sourceEventId, event]));
|
|
270
|
+
const firstKnockWindows = relevant.flatMap((battle) => {
|
|
271
|
+
const window = battle.firstKnockAdvantage;
|
|
272
|
+
if (!window)
|
|
273
|
+
return [];
|
|
274
|
+
return [{ battle, window }];
|
|
275
|
+
});
|
|
276
|
+
const advantageChanges = relevant.flatMap((battle) => battle.advantageChanges.map((change) => ({ battle, change })));
|
|
277
|
+
const reversals = advantageChanges.filter(({ change }) => change.classification === 'reversed');
|
|
278
|
+
const reversalOpportunityCount = advantageChanges.filter(({ change }) => change.previousLeadTeamRef !== null).length;
|
|
279
|
+
const responseOpportunities = relevant.flatMap((battle) => {
|
|
280
|
+
const first = battle.postKnockResponse.firstKnockEventId
|
|
281
|
+
? eventById.get(battle.postKnockResponse.firstKnockEventId)
|
|
282
|
+
: undefined;
|
|
283
|
+
if (!first?.actorTeamId || !trackedTeamIds.has(first.actorTeamId))
|
|
284
|
+
return [];
|
|
285
|
+
return [
|
|
286
|
+
{
|
|
287
|
+
battle,
|
|
288
|
+
first,
|
|
289
|
+
response: battle.postKnockResponse.responseEventId
|
|
290
|
+
? eventById.get(battle.postKnockResponse.responseEventId)
|
|
291
|
+
: undefined,
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
});
|
|
295
|
+
const thirdParty = relevant.flatMap((battle) => battle.possibleThirdParty.map((candidate) => ({ battle, candidate })));
|
|
296
|
+
const thirdPartyGroups = relevant.filter((battle) => battle.possibleThirdParty.length > 0);
|
|
297
|
+
const thirdPartyOpportunities = relevant.filter((battle) => battle.teamRefs.length >= 3);
|
|
298
|
+
const firstKnockCandidate = makeCandidate({
|
|
299
|
+
matchId,
|
|
300
|
+
ruleId: 'combat.first-knock-observed-advantage-duration',
|
|
301
|
+
category: 'combat',
|
|
302
|
+
title: '首个 knock 后的观测减员差持续时间',
|
|
303
|
+
statement: firstKnockWindows.length
|
|
304
|
+
? `${firstKnockWindows.length} 个当前小队相关 battle group 有可计算的首个 knock 观测差;平均持续 ${mean(firstKnockWindows.map((item) => item.window.durationSeconds)).toFixed(1)} 秒。`
|
|
305
|
+
: '没有同时具备首个 knock、双方队伍引用和后续状态证据的 battle group。',
|
|
306
|
+
numerator: firstKnockWindows.length
|
|
307
|
+
? sum(firstKnockWindows.map((item) => item.window.durationSeconds))
|
|
308
|
+
: null,
|
|
309
|
+
denominator: firstKnockWindows.length || null,
|
|
310
|
+
sampleSize: firstKnockWindows.length,
|
|
311
|
+
value: firstKnockWindows.length
|
|
312
|
+
? mean(firstKnockWindows.map((item) => item.window.durationSeconds))
|
|
313
|
+
: null,
|
|
314
|
+
unit: '秒 / 首个 knock 观测窗口',
|
|
315
|
+
support: firstKnockWindows.flatMap(({ window }) => window.evidenceEventIds.map((id) => eventRef(matchId, eventById.get(id), 'observed-knock-advantage'))),
|
|
316
|
+
counterexamples: firstKnockWindows
|
|
317
|
+
.filter(({ window }) => window.durationSeconds === 0)
|
|
318
|
+
.flatMap(({ window }) => window.evidenceEventIds.map((id) => eventRef(matchId, eventById.get(id), 'immediate-equalization'))),
|
|
319
|
+
complete: false,
|
|
320
|
+
statusOverride: firstKnockWindows.length ? 'incomplete' : 'insufficient_data',
|
|
321
|
+
reasons: [
|
|
322
|
+
'只统计当前事件中可确认的击倒/救援状态;敌队完整名单与全员状态未知,不表示整队人数优势。',
|
|
323
|
+
],
|
|
324
|
+
assumptions: [
|
|
325
|
+
'优势持续时间描述的是双方已观测成员的 knock 状态差,平局或反向时结束;当前 group 结束仍未平局时按最后交战秒数截断。',
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
const reversalCandidate = makeCandidate({
|
|
329
|
+
matchId,
|
|
330
|
+
ruleId: 'combat.observed-knock-advantage-reversal',
|
|
331
|
+
category: 'combat',
|
|
332
|
+
title: '观测 knock 平衡反转',
|
|
333
|
+
statement: `${reversals.length}/${reversalOpportunityCount} 个当前小队相关 knock 优势窗口,在另一侧已观测击倒数更低时发生反转;这不是整队战力判断。`,
|
|
334
|
+
numerator: reversals.length,
|
|
335
|
+
denominator: reversalOpportunityCount,
|
|
336
|
+
sampleSize: advantageChanges.length,
|
|
337
|
+
support: reversals.map(({ change }) => eventRef(matchId, eventById.get(change.sourceEventId), 'observed-knock-balance-reversal')),
|
|
338
|
+
counterexamples: advantageChanges
|
|
339
|
+
.filter(({ change }) => change.classification === 'equalized')
|
|
340
|
+
.map(({ change }) => eventRef(matchId, eventById.get(change.sourceEventId), 'observed-knock-balance-equalized')),
|
|
341
|
+
complete: false,
|
|
342
|
+
statusOverride: advantageChanges.length ? 'incomplete' : 'insufficient_data',
|
|
343
|
+
reasons: ['成员范围只覆盖本场交战中被遥测明确观测到的玩家;未观测成员不按存活或淘汰计算。'],
|
|
344
|
+
assumptions: [
|
|
345
|
+
'KNOCK/KILL 使该名观察成员计为被击倒或淘汰;REVIVE 清除击倒标记;每名成员只计一次。',
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
const responseCandidate = makeCandidate({
|
|
349
|
+
matchId,
|
|
350
|
+
ruleId: 'combat.subsequent-contact-after-first-knock',
|
|
351
|
+
category: 'combat',
|
|
352
|
+
title: '首个 knock 后继续有效接触',
|
|
353
|
+
statement: `${responseOpportunities.filter((item) => item.response).length}/${responseOpportunities.length} 个当前小队先 knock 的 battle group,在 30 秒内对同一对手队观察到有效命中、击倒或淘汰。`,
|
|
354
|
+
numerator: responseOpportunities.filter((item) => item.response).length,
|
|
355
|
+
denominator: responseOpportunities.length,
|
|
356
|
+
sampleSize: responseOpportunities.length,
|
|
357
|
+
support: responseOpportunities
|
|
358
|
+
.filter((item) => item.response)
|
|
359
|
+
.flatMap((item) => [
|
|
360
|
+
eventRef(matchId, item.first, 'first-knock'),
|
|
361
|
+
eventRef(matchId, item.response, 'subsequent-effective-contact'),
|
|
362
|
+
]),
|
|
363
|
+
counterexamples: responseOpportunities
|
|
364
|
+
.filter((item) => !item.response)
|
|
365
|
+
.map((item) => eventRef(matchId, item.first, 'no-subsequent-effective-contact-in-window')),
|
|
366
|
+
complete: responseOpportunities.length > 0 &&
|
|
367
|
+
responseOpportunities.every((item) => item.battle.postKnockResponse.status !== 'incomplete'),
|
|
368
|
+
reasons: responseOpportunities.length
|
|
369
|
+
? undefined
|
|
370
|
+
: ['当前小队没有队伍归属明确的首个 knock 作为响应机会。'],
|
|
371
|
+
assumptions: [
|
|
372
|
+
'响应须在首个 knock 后 30 秒内由同一攻击队对同一受击队产生有效 DAMAGE、KNOCK 或 KILL;不推断战术意图。',
|
|
373
|
+
],
|
|
374
|
+
});
|
|
375
|
+
const thirdPartyCandidate = makeCandidate({
|
|
376
|
+
matchId,
|
|
377
|
+
ruleId: 'combat.possible-third-party-contact',
|
|
378
|
+
category: 'combat',
|
|
379
|
+
title: '重叠交战中的可能第三队接触',
|
|
380
|
+
statement: `${thirdPartyGroups.length}/${thirdPartyOpportunities.length} 个当前小队相关多队 battle group 同时出现第三队接触证据;这只标记时间重叠,不证明第三方意图或因果。`,
|
|
381
|
+
numerator: thirdPartyGroups.length,
|
|
382
|
+
denominator: thirdPartyOpportunities.length,
|
|
383
|
+
sampleSize: thirdPartyOpportunities.length,
|
|
384
|
+
support: thirdParty.flatMap(({ candidate }) => candidate.evidenceEventIds.map((id) => eventRef(matchId, eventById.get(id), 'overlapping-third-team-contact'))),
|
|
385
|
+
counterexamples: relevant
|
|
386
|
+
.filter((battle) => battle.teamRefs.length === 2)
|
|
387
|
+
.flatMap((battle) => battle.bridgeEvidenceEventIds.map((id) => eventRef(matchId, eventById.get(id), 'two-team-overlap'))),
|
|
388
|
+
complete: relevant.length > 0,
|
|
389
|
+
...(relevant.length ? {} : { statusOverride: 'insufficient_data' }),
|
|
390
|
+
reasons: relevant.length ? undefined : ['没有当前小队参与且队伍关联可识别的 battle group。'],
|
|
391
|
+
assumptions: [
|
|
392
|
+
'仅在 pair episode 时间相交、共享已知队伍引用且各自有接触事件时成组;第三队标签是可能介入,不推断战术行为。',
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
return [firstKnockCandidate, reversalCandidate, responseCandidate, thirdPartyCandidate];
|
|
396
|
+
}
|
|
397
|
+
function analyzeFollowUps(matchId, episodes, events, trackedPlayerRefs, trackedPlayerIds, trackedTeamIds, eventById) {
|
|
398
|
+
const opportunities = episodes.flatMap((episode) => {
|
|
399
|
+
if (!trackedEpisode(episode) || !episode.firstKnock)
|
|
400
|
+
return [];
|
|
401
|
+
const firstEvent = eventById.get(episode.firstKnock.sourceEventId);
|
|
402
|
+
const trackedFirstKnock = Boolean((episode.firstKnock.attackerRef && trackedPlayerRefs.has(episode.firstKnock.attackerRef)) ||
|
|
403
|
+
(firstEvent?.actorTeamId && trackedTeamIds.has(firstEvent.actorTeamId)));
|
|
404
|
+
if (!trackedFirstKnock || !firstEvent?.victimTeamId)
|
|
405
|
+
return [];
|
|
406
|
+
const follow = events
|
|
407
|
+
.filter((event) => event.timeSeconds !== null &&
|
|
408
|
+
event.timeSeconds > episode.firstKnock.timeSeconds &&
|
|
409
|
+
event.timeSeconds <=
|
|
410
|
+
episode.firstKnock.timeSeconds + TACTICAL_FOLLOW_UP_WINDOW_SECONDS &&
|
|
411
|
+
event.victimTeamId === firstEvent.victimTeamId &&
|
|
412
|
+
((event.actorId && trackedPlayerIds.has(event.actorId)) ||
|
|
413
|
+
(event.actorTeamId && trackedTeamIds.has(event.actorTeamId))) &&
|
|
414
|
+
(event.type === 'DAMAGE'
|
|
415
|
+
? event.hit && event.damage > 0
|
|
416
|
+
: event.type === 'KNOCK' || event.type === 'KILL'))
|
|
417
|
+
.sort((left, right) => left.timeSeconds - right.timeSeconds ||
|
|
418
|
+
left.sourceEventId.localeCompare(right.sourceEventId));
|
|
419
|
+
return [{ episode, follow }];
|
|
420
|
+
});
|
|
421
|
+
const confirmed = opportunities.filter((item) => item.follow.length > 0);
|
|
422
|
+
const support = confirmed.flatMap((item) => [
|
|
423
|
+
item.episode.firstKnock
|
|
424
|
+
? eventRef(matchId, eventById.get(item.episode.firstKnock.sourceEventId), 'first-knock')
|
|
425
|
+
: null,
|
|
426
|
+
item.follow[0] ? eventRef(matchId, item.follow[0], 'follow-up-hit') : null,
|
|
427
|
+
]);
|
|
428
|
+
const counterexamples = opportunities
|
|
429
|
+
.filter((item) => !item.follow.length)
|
|
430
|
+
.map((item) => item.episode.firstKnock
|
|
431
|
+
? eventRef(matchId, eventById.get(item.episode.firstKnock.sourceEventId), 'no-follow-up-within-window')
|
|
432
|
+
: null);
|
|
433
|
+
return makeCandidate({
|
|
434
|
+
matchId,
|
|
435
|
+
ruleId: 'combat.follow-up-hit-window',
|
|
436
|
+
category: 'combat',
|
|
437
|
+
title: '首个 knock 后跟进命中',
|
|
438
|
+
statement: `${confirmed.length}/${opportunities.length} 个当前小队首个 knock 在 ${TACTICAL_FOLLOW_UP_WINDOW_SECONDS} 秒内有后续有效伤害、knock 或 kill 事件。`,
|
|
439
|
+
numerator: confirmed.length,
|
|
440
|
+
denominator: opportunities.length,
|
|
441
|
+
support,
|
|
442
|
+
counterexamples,
|
|
443
|
+
assumptions: [
|
|
444
|
+
`窗口为 ${TACTICAL_FOLLOW_UP_WINDOW_SECONDS} 秒;同一交战内只检查当前小队后续事件,不把 knock 本身重复算作命中。`,
|
|
445
|
+
],
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
function analyzeAdvantageConversion(matchId, episodes, trackedPlayerRefs) {
|
|
449
|
+
const trackedChains = episodes.filter(trackedEpisode).flatMap((episode) => episode.knockChains
|
|
450
|
+
.filter((chain) => episode.firstKnock?.sourceEventId === chain.knockEventId)
|
|
451
|
+
.filter((chain) => chain.attackerRef !== null && trackedPlayerRefs.has(chain.attackerRef))
|
|
452
|
+
.map((chain) => ({ episode, chain })));
|
|
453
|
+
const resolved = trackedChains.filter((item) => item.chain.outcome === 'KILL' || item.chain.outcome === 'REVIVE');
|
|
454
|
+
const kills = resolved.filter((item) => item.chain.outcome === 'KILL');
|
|
455
|
+
const revives = resolved.filter((item) => item.chain.outcome === 'REVIVE');
|
|
456
|
+
const support = kills.flatMap(({ chain }) => [
|
|
457
|
+
{
|
|
458
|
+
matchId,
|
|
459
|
+
eventId: chain.knockEventId,
|
|
460
|
+
timeSeconds: chain.knockAtSeconds,
|
|
461
|
+
kind: 'tracked-first-knock',
|
|
462
|
+
},
|
|
463
|
+
chain.outcomeEventId
|
|
464
|
+
? {
|
|
465
|
+
matchId,
|
|
466
|
+
eventId: chain.outcomeEventId,
|
|
467
|
+
timeSeconds: chain.outcomeAtSeconds,
|
|
468
|
+
kind: 'kill-conversion',
|
|
469
|
+
}
|
|
470
|
+
: null,
|
|
471
|
+
]);
|
|
472
|
+
const counterexamples = revives.flatMap(({ chain }) => [
|
|
473
|
+
{
|
|
474
|
+
matchId,
|
|
475
|
+
eventId: chain.knockEventId,
|
|
476
|
+
timeSeconds: chain.knockAtSeconds,
|
|
477
|
+
kind: 'tracked-knock',
|
|
478
|
+
},
|
|
479
|
+
chain.outcomeEventId
|
|
480
|
+
? {
|
|
481
|
+
matchId,
|
|
482
|
+
eventId: chain.outcomeEventId,
|
|
483
|
+
timeSeconds: chain.outcomeAtSeconds,
|
|
484
|
+
kind: 'revive-before-confirmation',
|
|
485
|
+
}
|
|
486
|
+
: null,
|
|
487
|
+
]);
|
|
488
|
+
const unresolved = trackedChains.length - resolved.length;
|
|
489
|
+
return makeCandidate({
|
|
490
|
+
matchId,
|
|
491
|
+
ruleId: 'combat.knock-kill-conversion',
|
|
492
|
+
category: 'combat',
|
|
493
|
+
title: '首个 knock → kill 转化(可识别样本)',
|
|
494
|
+
statement: `${kills.length}/${resolved.length} 个有唯一观察结果的当前小队 knock 在窗口内转成 kill;另有 ${unresolved} 个结果未知或未唯一匹配。`,
|
|
495
|
+
numerator: kills.length,
|
|
496
|
+
denominator: resolved.length,
|
|
497
|
+
support,
|
|
498
|
+
counterexamples,
|
|
499
|
+
complete: unresolved === 0,
|
|
500
|
+
reasons: unresolved
|
|
501
|
+
? [`${unresolved} 个 knock 链未进入转化率分母;缺少结果不当作失败。`]
|
|
502
|
+
: undefined,
|
|
503
|
+
assumptions: [
|
|
504
|
+
'只分析每个交战的首个 knock;仅把 R1 唯一关联的 KILL/REVIVE 纳入已知结果分母,这不是整场胜负转化率。',
|
|
505
|
+
],
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
function analyzeDistances(matchId, match, episodes, trackedPlayerIds, buckets, allPositionPoints, eventById) {
|
|
509
|
+
const rosterRefs = match.players
|
|
510
|
+
.filter((player) => trackedPlayerIds.has(player.accountId))
|
|
511
|
+
.map((player) => battlePlayerRef(matchId, player.accountId));
|
|
512
|
+
const distances = [...buckets.values()]
|
|
513
|
+
.flatMap((points) => pairDistances(points, matchId))
|
|
514
|
+
.sort((left, right) => right.distance - left.distance);
|
|
515
|
+
const sampledComplete = allPositionPoints.length > 0 && rosterRefs.length >= 2;
|
|
516
|
+
const sampled = makeCandidate({
|
|
517
|
+
matchId,
|
|
518
|
+
ruleId: 'coordination.sampled-teammate-distance',
|
|
519
|
+
category: 'coordination',
|
|
520
|
+
title: '采样时刻队友间距',
|
|
521
|
+
statement: distances.length
|
|
522
|
+
? `同一 ${TACTICAL_POSITION_BUCKET_SECONDS} 秒采样桶内,共有 ${distances.length} 个队友位置对;平均平面间距 ${mean(distances.map((item) => item.distance)).toFixed(1)} 遥测坐标单位。`
|
|
523
|
+
: '没有足够的同步队友位置样本计算间距。',
|
|
524
|
+
numerator: distances.length ? sum(distances.map((item) => item.distance)) : null,
|
|
525
|
+
denominator: distances.length ? distances.length : sampledComplete ? 0 : null,
|
|
526
|
+
sampleSize: distances.length,
|
|
527
|
+
value: distances.length ? mean(distances.map((item) => item.distance)) : null,
|
|
528
|
+
unit: '遥测坐标单位 / 队友位置对',
|
|
529
|
+
support: distances.slice(0, TACTICAL_MAX_EVIDENCE_REFS).flatMap((item) => item.refs),
|
|
530
|
+
counterexamples: [...distances]
|
|
531
|
+
.reverse()
|
|
532
|
+
.slice(0, TACTICAL_MAX_EVIDENCE_REFS)
|
|
533
|
+
.flatMap((item) => item.refs),
|
|
534
|
+
complete: sampledComplete && distances.length > 0,
|
|
535
|
+
reasons: !allPositionPoints.length
|
|
536
|
+
? ['该比赛没有带可用时间和 X/Y 坐标的当前小队 POSITION 事件。']
|
|
537
|
+
: rosterRefs.length < 2
|
|
538
|
+
? ['当前比赛内可验证的本队队员少于 2 位。']
|
|
539
|
+
: !distances.length
|
|
540
|
+
? ['存在位置事件,但没有至少两名队员落在同一 15 秒采样桶。']
|
|
541
|
+
: undefined,
|
|
542
|
+
assumptions: [
|
|
543
|
+
'每位队员每 15 秒桶保留最早的有效位置事件;只计算 X/Y 平面欧氏距离,不推断视野。',
|
|
544
|
+
],
|
|
545
|
+
});
|
|
546
|
+
const onsetObservations = episodes.filter(trackedEpisode).flatMap((episode) => {
|
|
547
|
+
const onset = engagementAnchor(episode, eventById);
|
|
548
|
+
const sameBucket = buckets.get(Math.floor(episode.startSeconds / TACTICAL_POSITION_BUCKET_SECONDS)) ?? [];
|
|
549
|
+
const pairValues = pairDistances(sameBucket, matchId);
|
|
550
|
+
if (!pairValues.length)
|
|
551
|
+
return [];
|
|
552
|
+
return [{ episode, onset, pairs: pairValues }];
|
|
553
|
+
});
|
|
554
|
+
const onsetDistances = onsetObservations.flatMap((item) => item.pairs);
|
|
555
|
+
const onsetValue = onsetDistances.length
|
|
556
|
+
? mean(onsetDistances.map((item) => item.distance))
|
|
557
|
+
: null;
|
|
558
|
+
const onset = makeCandidate({
|
|
559
|
+
matchId,
|
|
560
|
+
ruleId: 'coordination.combat-onset-separation',
|
|
561
|
+
category: 'coordination',
|
|
562
|
+
title: '开战时队友分离度',
|
|
563
|
+
statement: onsetValue === null
|
|
564
|
+
? '没有交战起始时刻对齐的同步队友位置样本。'
|
|
565
|
+
: `${onsetObservations.length}/${episodes.filter(trackedEpisode).length} 个当前小队交战有起始桶位置;样本平均平面间距 ${onsetValue.toFixed(1)} 坐标单位。`,
|
|
566
|
+
numerator: onsetDistances.length ? sum(onsetDistances.map((item) => item.distance)) : null,
|
|
567
|
+
denominator: onsetDistances.length ? onsetDistances.length : null,
|
|
568
|
+
sampleSize: onsetDistances.length,
|
|
569
|
+
value: onsetValue,
|
|
570
|
+
unit: '遥测坐标单位 / 同步队友位置对',
|
|
571
|
+
support: onsetObservations
|
|
572
|
+
.slice(0, TACTICAL_MAX_EVIDENCE_REFS)
|
|
573
|
+
.flatMap((item) => [
|
|
574
|
+
eventRef(matchId, item.onset, 'engagement-onset'),
|
|
575
|
+
...item.pairs.flatMap((pair) => pair.refs),
|
|
576
|
+
]),
|
|
577
|
+
counterexamples: [...onsetObservations]
|
|
578
|
+
.reverse()
|
|
579
|
+
.slice(0, TACTICAL_MAX_EVIDENCE_REFS)
|
|
580
|
+
.flatMap((item) => item.pairs
|
|
581
|
+
.filter((pair) => pair.distance <= TACTICAL_ISOLATION_DISTANCE_UNITS)
|
|
582
|
+
.flatMap((pair) => pair.refs)),
|
|
583
|
+
complete: onsetDistances.length > 0 &&
|
|
584
|
+
onsetObservations.length === episodes.filter(trackedEpisode).length,
|
|
585
|
+
reasons: onsetObservations.length < episodes.filter(trackedEpisode).length
|
|
586
|
+
? [
|
|
587
|
+
`${episodes.filter(trackedEpisode).length - onsetObservations.length} 个当前小队交战没有足够的起始桶同步位置。`,
|
|
588
|
+
]
|
|
589
|
+
: undefined,
|
|
590
|
+
assumptions: [
|
|
591
|
+
`使用 floor(交战起始秒 / ${TACTICAL_POSITION_BUCKET_SECONDS}) 的位置桶;只统计 X/Y 距离,不推断视野或因果。`,
|
|
592
|
+
],
|
|
593
|
+
});
|
|
594
|
+
const isolationRows = episodes.filter(trackedEpisode).flatMap((episode) => {
|
|
595
|
+
const allTeamMembers = (buckets.get(Math.floor(episode.startSeconds / TACTICAL_POSITION_BUCKET_SECONDS)) ?? []).filter((point) => rosterRefs.includes(point.playerRef));
|
|
596
|
+
const contactPlayers = episodeContactPlayers(episode);
|
|
597
|
+
const engagedRefs = rosterRefs.filter((ref) => contactPlayers.has(ref));
|
|
598
|
+
const engagedTeamIds = new Set(allTeamMembers
|
|
599
|
+
.filter((point) => engagedRefs.includes(point.playerRef))
|
|
600
|
+
.map((point) => point.teamId)
|
|
601
|
+
.filter((id) => Boolean(id)));
|
|
602
|
+
if (engagedTeamIds.size !== 1)
|
|
603
|
+
return [];
|
|
604
|
+
const teamMembers = allTeamMembers.filter((point) => point.teamId && engagedTeamIds.has(point.teamId));
|
|
605
|
+
if (teamMembers.length < 2)
|
|
606
|
+
return [];
|
|
607
|
+
const engaged = engagedRefs;
|
|
608
|
+
if (!engaged.length)
|
|
609
|
+
return [];
|
|
610
|
+
const anchor = engagementAnchor(episode, eventById);
|
|
611
|
+
if (engaged.length !== 1)
|
|
612
|
+
return [
|
|
613
|
+
{
|
|
614
|
+
isolated: false,
|
|
615
|
+
refs: [
|
|
616
|
+
eventRef(matchId, anchor, 'multi-member-engagement'),
|
|
617
|
+
...teamMembers.map((point) => eventRef(matchId, point.event, 'sampled-position')),
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
];
|
|
621
|
+
const engagedPoint = teamMembers.find((point) => point.playerRef === engaged[0]);
|
|
622
|
+
if (!engagedPoint)
|
|
623
|
+
return [];
|
|
624
|
+
const supportPoints = teamMembers.filter((point) => point.playerRef !== engagedPoint.playerRef);
|
|
625
|
+
if (!supportPoints.length)
|
|
626
|
+
return [];
|
|
627
|
+
const minDistance = Math.min(...supportPoints.map((point) => Math.hypot(engagedPoint.x - point.x, engagedPoint.y - point.y)));
|
|
628
|
+
return [
|
|
629
|
+
{
|
|
630
|
+
isolated: minDistance > TACTICAL_ISOLATION_DISTANCE_UNITS,
|
|
631
|
+
refs: [
|
|
632
|
+
eventRef(matchId, anchor, minDistance > TACTICAL_ISOLATION_DISTANCE_UNITS
|
|
633
|
+
? 'single-member-contact'
|
|
634
|
+
: 'nearby-teammate-contact'),
|
|
635
|
+
eventRef(matchId, engagedPoint.event, 'engaged-player-position'),
|
|
636
|
+
...supportPoints.map((point) => eventRef(matchId, point.event, 'teammate-position')),
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
];
|
|
640
|
+
});
|
|
641
|
+
const isolatedCount = isolationRows.filter((item) => item.isolated).length;
|
|
642
|
+
const isolation = makeCandidate({
|
|
643
|
+
matchId,
|
|
644
|
+
ruleId: 'coordination.sampled-isolation-candidate',
|
|
645
|
+
category: 'coordination',
|
|
646
|
+
title: '采样分离的单人交战候选',
|
|
647
|
+
statement: `${isolatedCount}/${isolationRows.length} 个可比较交战仅有 1 位当前队员参与,且所有已采样队友距其超过 ${TACTICAL_ISOLATION_DISTANCE_UNITS} 坐标单位。`,
|
|
648
|
+
numerator: isolatedCount,
|
|
649
|
+
denominator: isolationRows.length,
|
|
650
|
+
support: isolationRows.filter((item) => item.isolated).flatMap((item) => item.refs),
|
|
651
|
+
counterexamples: isolationRows.filter((item) => !item.isolated).flatMap((item) => item.refs),
|
|
652
|
+
complete: isolationRows.length > 0,
|
|
653
|
+
reasons: isolationRows.length
|
|
654
|
+
? undefined
|
|
655
|
+
: ['没有同时观测至少两名队员、且当前队员参与的交战起始采样。'],
|
|
656
|
+
assumptions: [
|
|
657
|
+
`“分离”仅指起始 15 秒桶中单人参与且与所有其他已采样队友相距 >${TACTICAL_ISOLATION_DISTANCE_UNITS} 坐标单位;不推断视野、通信或支援意图。`,
|
|
658
|
+
],
|
|
659
|
+
});
|
|
660
|
+
return { sampled, onset, isolation };
|
|
661
|
+
}
|
|
662
|
+
function analyzeResponseWindows(matchId, events, trackedPlayerIds, trackedTeamIds) {
|
|
663
|
+
const incomingKnocks = events.filter((event) => event.type === 'KNOCK' &&
|
|
664
|
+
event.phase === 'in_match' &&
|
|
665
|
+
event.timeSeconds !== null &&
|
|
666
|
+
event.actorTeamId &&
|
|
667
|
+
event.victimTeamId &&
|
|
668
|
+
trackedTeamIds.has(event.victimTeamId) &&
|
|
669
|
+
!trackedTeamIds.has(event.actorTeamId) &&
|
|
670
|
+
event.victimId &&
|
|
671
|
+
trackedPlayerIds.has(event.victimId));
|
|
672
|
+
const incompleteKnocks = events.filter((event) => event.type === 'KNOCK' &&
|
|
673
|
+
event.phase === 'in_match' &&
|
|
674
|
+
event.victimId &&
|
|
675
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
676
|
+
(event.timeSeconds === null || !event.actorTeamId || !event.victimTeamId));
|
|
677
|
+
const observations = incomingKnocks.map((knock) => {
|
|
678
|
+
const response = events
|
|
679
|
+
.filter((event) => isContact(event) &&
|
|
680
|
+
event.phase === 'in_match' &&
|
|
681
|
+
event.timeSeconds !== null &&
|
|
682
|
+
event.timeSeconds >= knock.timeSeconds &&
|
|
683
|
+
event.timeSeconds <= knock.timeSeconds + TACTICAL_RESPONSE_WINDOW_SECONDS &&
|
|
684
|
+
event.actorId &&
|
|
685
|
+
trackedPlayerIds.has(event.actorId) &&
|
|
686
|
+
event.actorId !== knock.victimId &&
|
|
687
|
+
event.actorTeamId &&
|
|
688
|
+
trackedTeamIds.has(event.actorTeamId) &&
|
|
689
|
+
event.victimTeamId === knock.actorTeamId &&
|
|
690
|
+
isEffective(event))
|
|
691
|
+
.sort((left, right) => left.timeSeconds - right.timeSeconds ||
|
|
692
|
+
left.sourceEventId.localeCompare(right.sourceEventId));
|
|
693
|
+
return { knock, response: response[0] ?? null };
|
|
694
|
+
});
|
|
695
|
+
const answered = observations.filter((item) => item.response);
|
|
696
|
+
const intervals = answered.map((item) => item.response.timeSeconds - item.knock.timeSeconds);
|
|
697
|
+
return makeCandidate({
|
|
698
|
+
matchId,
|
|
699
|
+
ruleId: 'coordination.response-after-ally-knock',
|
|
700
|
+
category: 'coordination',
|
|
701
|
+
title: '队友被 knock 后的反应窗口',
|
|
702
|
+
statement: `${answered.length}/${observations.length} 个对手 knock 到当前队员后,${TACTICAL_RESPONSE_WINDOW_SECONDS} 秒内观察到其他队员对同一对手队的有效反击${intervals.length ? `;已观察反应平均 ${mean(intervals).toFixed(1)} 秒` : ''}。`,
|
|
703
|
+
numerator: answered.length,
|
|
704
|
+
denominator: observations.length,
|
|
705
|
+
sampleSize: observations.length,
|
|
706
|
+
value: intervals.length ? mean(intervals) : null,
|
|
707
|
+
unit: '已响应 / knock 机会;反应延迟为秒',
|
|
708
|
+
support: answered.flatMap((item) => [
|
|
709
|
+
eventRef(matchId, item.knock, 'ally-knocked'),
|
|
710
|
+
eventRef(matchId, item.response, 'teammate-response'),
|
|
711
|
+
]),
|
|
712
|
+
counterexamples: observations
|
|
713
|
+
.filter((item) => !item.response)
|
|
714
|
+
.map((item) => eventRef(matchId, item.knock, 'no-response-observed-in-window')),
|
|
715
|
+
complete: incompleteKnocks.length === 0,
|
|
716
|
+
reasons: incompleteKnocks.length
|
|
717
|
+
? [`${incompleteKnocks.length} 条本队成员 knock 缺少时间或明确队伍归属,未作为已知响应机会。`]
|
|
718
|
+
: undefined,
|
|
719
|
+
assumptions: [
|
|
720
|
+
`机会单位为对手有时间且队伍已知的每条本队成员 KNOCK;响应须由另一名当前队员在 ${TACTICAL_RESPONSE_WINDOW_SECONDS} 秒内对同一对手队产生有效 DAMAGE/KNOCK/KILL。`,
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function trackedTeamIdsFor(events, trackedPlayerIds) {
|
|
725
|
+
const result = new Set();
|
|
726
|
+
for (const event of events) {
|
|
727
|
+
if (event.actorId && trackedPlayerIds.has(event.actorId) && event.actorTeamId)
|
|
728
|
+
result.add(event.actorTeamId);
|
|
729
|
+
if (event.victimId && trackedPlayerIds.has(event.victimId) && event.victimTeamId)
|
|
730
|
+
result.add(event.victimTeamId);
|
|
731
|
+
}
|
|
732
|
+
return result;
|
|
733
|
+
}
|
|
734
|
+
function analyzeSocial(matchId, match, events, episodes, trackedPlayerIds, trackedTeamIds, sourceComplete) {
|
|
735
|
+
const teamDamage = events.filter((event) => event.type === 'DAMAGE' &&
|
|
736
|
+
event.phase === 'in_match' &&
|
|
737
|
+
event.actorId &&
|
|
738
|
+
event.victimId &&
|
|
739
|
+
event.actorId !== event.victimId &&
|
|
740
|
+
trackedPlayerIds.has(event.actorId) &&
|
|
741
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
742
|
+
event.actorTeamId &&
|
|
743
|
+
event.actorTeamId === event.victimTeamId &&
|
|
744
|
+
trackedTeamIds.has(event.actorTeamId));
|
|
745
|
+
const uncertainTeamDamage = events.filter((event) => event.type === 'DAMAGE' &&
|
|
746
|
+
event.phase === 'in_match' &&
|
|
747
|
+
event.actorId &&
|
|
748
|
+
event.victimId &&
|
|
749
|
+
event.actorId !== event.victimId &&
|
|
750
|
+
trackedPlayerIds.has(event.actorId) &&
|
|
751
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
752
|
+
(!event.actorTeamId || !event.victimTeamId || event.actorTeamId !== event.victimTeamId));
|
|
753
|
+
const positiveFriendlyDamage = teamDamage.filter((event) => event.hit && event.damage > 0);
|
|
754
|
+
const friendlyFire = makeCandidate({
|
|
755
|
+
matchId,
|
|
756
|
+
ruleId: 'social.directed-friendly-damage',
|
|
757
|
+
category: 'social',
|
|
758
|
+
title: '定向友伤事件',
|
|
759
|
+
statement: `${positiveFriendlyDamage.length} 条有正伤害数值的本队队员→队员 DAMAGE 事件(已识别同队 DAMAGE 记录 ${teamDamage.length} 条)。`,
|
|
760
|
+
numerator: positiveFriendlyDamage.length,
|
|
761
|
+
denominator: teamDamage.length,
|
|
762
|
+
support: positiveFriendlyDamage.map((event) => eventRef(matchId, event, 'directed-friendly-damage')),
|
|
763
|
+
counterexamples: teamDamage
|
|
764
|
+
.filter((event) => !(event.hit && event.damage > 0))
|
|
765
|
+
.map((event) => eventRef(matchId, event, 'same-team-damage-without-positive-hit')),
|
|
766
|
+
complete: teamDamage.length > 0 && uncertainTeamDamage.length === 0,
|
|
767
|
+
reasons: [
|
|
768
|
+
...(!teamDamage.length
|
|
769
|
+
? ['未发现可识别的本队成员间 DAMAGE 记录;这不是“没有友伤”的结论。']
|
|
770
|
+
: []),
|
|
771
|
+
...(uncertainTeamDamage.length
|
|
772
|
+
? [
|
|
773
|
+
`另有 ${uncertainTeamDamage.length} 条本队成员间 DAMAGE 的队伍字段缺失或冲突,未计为友伤/反例。`,
|
|
774
|
+
]
|
|
775
|
+
: []),
|
|
776
|
+
],
|
|
777
|
+
assumptions: [
|
|
778
|
+
'只有 actor/victim 都属于选中小队、同队 ID 明确且 damage>0/hit=true 的事件计为确认友伤;方向保留 actor → victim。',
|
|
779
|
+
],
|
|
780
|
+
});
|
|
781
|
+
const reviveEvents = events.filter((event) => event.type === 'REVIVE' &&
|
|
782
|
+
event.phase === 'in_match' &&
|
|
783
|
+
event.actorId &&
|
|
784
|
+
event.victimId &&
|
|
785
|
+
trackedPlayerIds.has(event.actorId) &&
|
|
786
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
787
|
+
event.actorTeamId &&
|
|
788
|
+
event.actorTeamId === event.victimTeamId &&
|
|
789
|
+
trackedTeamIds.has(event.actorTeamId));
|
|
790
|
+
const allKnockedMembers = events.filter((event) => event.type === 'KNOCK' &&
|
|
791
|
+
event.phase === 'in_match' &&
|
|
792
|
+
event.victimId &&
|
|
793
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
794
|
+
event.victimTeamId &&
|
|
795
|
+
trackedTeamIds.has(event.victimTeamId));
|
|
796
|
+
const uncertainRescueEvents = events.filter((event) => (event.type === 'KNOCK' || event.type === 'REVIVE') &&
|
|
797
|
+
event.phase === 'in_match' &&
|
|
798
|
+
((event.type === 'KNOCK' &&
|
|
799
|
+
event.victimId &&
|
|
800
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
801
|
+
(event.timeSeconds === null || !event.victimTeamId)) ||
|
|
802
|
+
(event.type === 'REVIVE' &&
|
|
803
|
+
event.actorId &&
|
|
804
|
+
event.victimId &&
|
|
805
|
+
trackedPlayerIds.has(event.actorId) &&
|
|
806
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
807
|
+
(!event.actorTeamId || !event.victimTeamId || event.actorTeamId !== event.victimTeamId))));
|
|
808
|
+
const knockedMembers = allKnockedMembers.filter((event) => event.timeSeconds !== null);
|
|
809
|
+
const matchedRescues = [];
|
|
810
|
+
const usedKnocks = new Set();
|
|
811
|
+
for (const revive of [...reviveEvents].sort((left, right) => (left.timeSeconds ?? Number.POSITIVE_INFINITY) -
|
|
812
|
+
(right.timeSeconds ?? Number.POSITIVE_INFINITY))) {
|
|
813
|
+
const knock = knockedMembers
|
|
814
|
+
.filter((candidate) => !usedKnocks.has(candidate.sourceEventId) &&
|
|
815
|
+
candidate.victimId === revive.victimId &&
|
|
816
|
+
candidate.timeSeconds !== null &&
|
|
817
|
+
revive.timeSeconds !== null &&
|
|
818
|
+
candidate.timeSeconds <= revive.timeSeconds &&
|
|
819
|
+
revive.timeSeconds - candidate.timeSeconds <= 120)
|
|
820
|
+
.sort((left, right) => right.timeSeconds - left.timeSeconds ||
|
|
821
|
+
left.sourceEventId.localeCompare(right.sourceEventId))[0];
|
|
822
|
+
if (!knock)
|
|
823
|
+
continue;
|
|
824
|
+
usedKnocks.add(knock.sourceEventId);
|
|
825
|
+
matchedRescues.push({ revive, knock, elapsed: revive.timeSeconds - knock.timeSeconds });
|
|
826
|
+
}
|
|
827
|
+
const rescue = makeCandidate({
|
|
828
|
+
matchId,
|
|
829
|
+
ruleId: 'social.directed-rescue',
|
|
830
|
+
category: 'social',
|
|
831
|
+
title: '队内救援',
|
|
832
|
+
statement: `${matchedRescues.length} 条本队 REVIVE 能在 120 秒内与同一被救队员的 knock 对齐;观察到 ${reviveEvents.length} 条本队 REVIVE。`,
|
|
833
|
+
numerator: matchedRescues.length,
|
|
834
|
+
denominator: knockedMembers.length,
|
|
835
|
+
support: matchedRescues.flatMap((item) => [
|
|
836
|
+
eventRef(matchId, item.knock, 'knocked-teammate'),
|
|
837
|
+
eventRef(matchId, item.revive, 'directed-rescue'),
|
|
838
|
+
]),
|
|
839
|
+
counterexamples: knockedMembers
|
|
840
|
+
.filter((knock) => !matchedRescues.some((item) => item.knock.sourceEventId === knock.sourceEventId))
|
|
841
|
+
.map((event) => eventRef(matchId, event, 'no-matching-rescue-observed')),
|
|
842
|
+
complete: knockedMembers.length > 0 &&
|
|
843
|
+
knockedMembers.length === allKnockedMembers.length &&
|
|
844
|
+
uncertainRescueEvents.length === 0,
|
|
845
|
+
reasons: [
|
|
846
|
+
...(!allKnockedMembers.length ? ['当前队员 knock 事件没有可识别机会样本;救援率未知。'] : []),
|
|
847
|
+
...(knockedMembers.length < allKnockedMembers.length
|
|
848
|
+
? [
|
|
849
|
+
`${allKnockedMembers.length - knockedMembers.length} 个队员 knock 缺少时间,未进入分母。`,
|
|
850
|
+
]
|
|
851
|
+
: []),
|
|
852
|
+
...(uncertainRescueEvents.length
|
|
853
|
+
? [`${uncertainRescueEvents.length} 条队内救援/knock 记录有队伍归属缺失或冲突。`]
|
|
854
|
+
: []),
|
|
855
|
+
],
|
|
856
|
+
assumptions: [
|
|
857
|
+
'救援机会为受 knock 的本队队员;救援只在 120 秒内同一 victim 的队内 REVIVE 事件存在时匹配。',
|
|
858
|
+
],
|
|
859
|
+
});
|
|
860
|
+
const activeEpisodes = episodes.filter((episode) => episode.teams.some((team) => team.tracked));
|
|
861
|
+
const pairObservations = [];
|
|
862
|
+
const eventById = new Map(events.map((event) => [event.sourceEventId, event]));
|
|
863
|
+
const trackedRoster = match.players
|
|
864
|
+
.filter((player) => trackedPlayerIds.has(player.accountId))
|
|
865
|
+
.map((player) => ({
|
|
866
|
+
matchRef: battlePlayerRef(matchId, player.accountId),
|
|
867
|
+
stableRef: tacticalPlayerRef(player.accountId),
|
|
868
|
+
}))
|
|
869
|
+
.sort((left, right) => left.stableRef.localeCompare(right.stableRef));
|
|
870
|
+
for (let leftIndex = 0; leftIndex < trackedRoster.length; leftIndex += 1) {
|
|
871
|
+
for (let rightIndex = leftIndex + 1; rightIndex < trackedRoster.length; rightIndex += 1) {
|
|
872
|
+
const left = trackedRoster[leftIndex];
|
|
873
|
+
const right = trackedRoster[rightIndex];
|
|
874
|
+
const pair = pairId(left.stableRef, right.stableRef);
|
|
875
|
+
const matchRefs = [left.matchRef, right.matchRef];
|
|
876
|
+
const eligibleEpisodes = activeEpisodes.filter((episode) => matchRefs.some((ref) => episodeContactPlayers(episode).has(ref)));
|
|
877
|
+
const shared = eligibleEpisodes.filter((episode) => matchRefs.every((ref) => episodeContactPlayers(episode).has(ref)));
|
|
878
|
+
const supportEvents = shared.flatMap((episode) => episode.timeline
|
|
879
|
+
.filter((event) => (event.type === 'DAMAGE' || event.type === 'KNOCK' || event.type === 'KILL') &&
|
|
880
|
+
(matchRefs.includes(event.actorRef ?? '') ||
|
|
881
|
+
matchRefs.includes(event.victimRef ?? '')))
|
|
882
|
+
.map((event) => eventRef(matchId, eventById.get(event.sourceEventId), 'shared-engagement-contact')));
|
|
883
|
+
const counterexamples = eligibleEpisodes
|
|
884
|
+
.filter((episode) => !matchRefs.every((ref) => episodeContactPlayers(episode).has(ref)))
|
|
885
|
+
.flatMap((episode) => episode.timeline
|
|
886
|
+
.filter((event) => (event.type === 'DAMAGE' || event.type === 'KNOCK' || event.type === 'KILL') &&
|
|
887
|
+
(matchRefs.includes(event.actorRef ?? '') ||
|
|
888
|
+
matchRefs.includes(event.victimRef ?? '')))
|
|
889
|
+
.map((event) => eventRef(matchId, eventById.get(event.sourceEventId), 'only-one-pair-member-in-contact')));
|
|
890
|
+
pairObservations.push({
|
|
891
|
+
...pair,
|
|
892
|
+
matchId,
|
|
893
|
+
eligible: events.some((event) => event.phase === 'in_match'),
|
|
894
|
+
sourceComplete,
|
|
895
|
+
opportunityCount: eligibleEpisodes.length,
|
|
896
|
+
sharedEngagementCount: shared.length,
|
|
897
|
+
supportEvents: uniqueRefs(supportEvents),
|
|
898
|
+
counterexamples: uniqueRefs(counterexamples.length
|
|
899
|
+
? counterexamples
|
|
900
|
+
: shared.length === 0
|
|
901
|
+
? [matchRef(matchId, 'no-joint-engagement-observed')]
|
|
902
|
+
: []),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
const pairs = pairObservations.map((observation) => makeCandidate({
|
|
907
|
+
matchId,
|
|
908
|
+
ruleId: 'social.shared-engagement-pair',
|
|
909
|
+
category: 'social',
|
|
910
|
+
title: '共同交战关系',
|
|
911
|
+
statement: `${observation.sharedEngagementCount} 个重建交战同时出现这对当前队员。共同出现只描述事件,不代表关系或偏好。`,
|
|
912
|
+
numerator: observation.sharedEngagementCount,
|
|
913
|
+
denominator: observation.opportunityCount,
|
|
914
|
+
sampleSize: observation.opportunityCount,
|
|
915
|
+
support: observation.supportEvents,
|
|
916
|
+
counterexamples: observation.counterexamples,
|
|
917
|
+
complete: activeEpisodes.length > 0 && sourceComplete,
|
|
918
|
+
reasons: activeEpisodes.length ? undefined : ['没有可分析的当前小队交战片段。'],
|
|
919
|
+
assumptions: [
|
|
920
|
+
'每个 R1 episode 计一次;共同出现通过匹配的 episode 参与者引用确认,不推断亲密度或意图。',
|
|
921
|
+
],
|
|
922
|
+
pairRefs: observation.pairRefs,
|
|
923
|
+
}));
|
|
924
|
+
const playfulMatches = positiveFriendlyDamage.flatMap((damage) => {
|
|
925
|
+
const rescueEvent = reviveEvents
|
|
926
|
+
.filter((revive) => revive.actorId === damage.actorId &&
|
|
927
|
+
revive.victimId === damage.victimId &&
|
|
928
|
+
damage.timeSeconds !== null &&
|
|
929
|
+
revive.timeSeconds !== null &&
|
|
930
|
+
revive.timeSeconds >= damage.timeSeconds &&
|
|
931
|
+
revive.timeSeconds - damage.timeSeconds <= 120)
|
|
932
|
+
.sort((left, right) => left.timeSeconds - right.timeSeconds)[0];
|
|
933
|
+
return rescueEvent ? [{ damage, revive: rescueEvent }] : [];
|
|
934
|
+
});
|
|
935
|
+
const playful = makeCandidate({
|
|
936
|
+
matchId,
|
|
937
|
+
ruleId: 'social.friendly-fire-followed-by-rescue',
|
|
938
|
+
category: 'social',
|
|
939
|
+
title: '友伤后由同一队员救援的叙事候选',
|
|
940
|
+
statement: `${playfulMatches.length} 组同一方向的确认友伤 → 120 秒内救援事件;这是可复核事件组合,不代表“开玩笑”或任何动机。`,
|
|
941
|
+
numerator: playfulMatches.length,
|
|
942
|
+
denominator: positiveFriendlyDamage.length,
|
|
943
|
+
support: playfulMatches.flatMap((item) => [
|
|
944
|
+
eventRef(matchId, item.damage, 'directed-friendly-damage'),
|
|
945
|
+
eventRef(matchId, item.revive, 'same-actor-rescue'),
|
|
946
|
+
]),
|
|
947
|
+
counterexamples: positiveFriendlyDamage
|
|
948
|
+
.filter((damage) => !playfulMatches.some((item) => item.damage.sourceEventId === damage.sourceEventId))
|
|
949
|
+
.map((event) => eventRef(matchId, event, 'friendly-damage-without-following-rescue')),
|
|
950
|
+
complete: positiveFriendlyDamage.length > 0,
|
|
951
|
+
reasons: positiveFriendlyDamage.length
|
|
952
|
+
? undefined
|
|
953
|
+
: ['没有确认友伤事件,不能构造这个事件组合候选。'],
|
|
954
|
+
assumptions: [
|
|
955
|
+
'同一 actor 对同一 victim 在 120 秒内出现 REVIVE;不推断友好、玩笑、计划或因果。',
|
|
956
|
+
],
|
|
957
|
+
});
|
|
958
|
+
return { friendlyFire, rescue, pairs, playful, observations: pairObservations };
|
|
959
|
+
}
|
|
960
|
+
function analyzeCircleWindows(matchId, episodes, reconstruction, eventById) {
|
|
961
|
+
const opportunities = episodes.filter(trackedEpisode);
|
|
962
|
+
const transitions = reconstruction.circleTransitions.filter((transition) => transition.changedZones.some((zone) => zone === 'safetyZone' || zone === 'poisonGasWarningZone'));
|
|
963
|
+
const rows = opportunities.map((episode) => {
|
|
964
|
+
const nearby = transitions
|
|
965
|
+
.filter((transition) => Math.abs(transition.timeSeconds - episode.startSeconds) <= TACTICAL_CIRCLE_WINDOW_SECONDS)
|
|
966
|
+
.sort((left, right) => Math.abs(left.timeSeconds - episode.startSeconds) -
|
|
967
|
+
Math.abs(right.timeSeconds - episode.startSeconds));
|
|
968
|
+
return { episode, transition: nearby[0] ?? null };
|
|
969
|
+
});
|
|
970
|
+
const matched = rows.filter((row) => row.transition);
|
|
971
|
+
return makeCandidate({
|
|
972
|
+
matchId,
|
|
973
|
+
ruleId: 'rotation.engagement-near-circle-transition',
|
|
974
|
+
category: 'rotation',
|
|
975
|
+
title: '圈状态窗口内的交战',
|
|
976
|
+
statement: `${matched.length}/${rows.length} 个当前小队交战起点距已观测安全圈/毒圈半径变化不超过 ${TACTICAL_CIRCLE_WINDOW_SECONDS} 秒。`,
|
|
977
|
+
numerator: matched.length,
|
|
978
|
+
denominator: rows.length,
|
|
979
|
+
support: matched.flatMap((row) => [
|
|
980
|
+
eventRef(matchId, engagementAnchor(row.episode, eventById), 'engagement'),
|
|
981
|
+
{
|
|
982
|
+
matchId,
|
|
983
|
+
eventId: row.transition.fromSourceEventId,
|
|
984
|
+
timeSeconds: row.transition.timeSeconds,
|
|
985
|
+
kind: 'circle-window-before',
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
matchId,
|
|
989
|
+
eventId: row.transition.toSourceEventId,
|
|
990
|
+
timeSeconds: row.transition.timeSeconds,
|
|
991
|
+
kind: 'circle-window-after',
|
|
992
|
+
},
|
|
993
|
+
]),
|
|
994
|
+
counterexamples: rows
|
|
995
|
+
.filter((row) => !row.transition)
|
|
996
|
+
.map((row) => eventRef(matchId, engagementAnchor(row.episode, eventById), 'no-nearby-circle-transition')),
|
|
997
|
+
complete: transitions.length > 0 && rows.length > 0,
|
|
998
|
+
reasons: !transitions.length
|
|
999
|
+
? ['Telemetry 未提供可比较的安全圈/毒圈半径变化样本。']
|
|
1000
|
+
: !rows.length
|
|
1001
|
+
? ['当前小队没有可关联的重建交战。']
|
|
1002
|
+
: undefined,
|
|
1003
|
+
assumptions: [
|
|
1004
|
+
`只把采样中安全圈或毒圈预警半径变化作为窗口锚点;窗口为 ±${TACTICAL_CIRCLE_WINDOW_SECONDS} 秒,不声称变化导致交战。`,
|
|
1005
|
+
],
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
function analyzeMovement(matchId, points) {
|
|
1009
|
+
const byPlayer = new Map();
|
|
1010
|
+
for (const point of points)
|
|
1011
|
+
byPlayer.set(point.playerRef, [...(byPlayer.get(point.playerRef) ?? []), point]);
|
|
1012
|
+
const segments = [...byPlayer.values()]
|
|
1013
|
+
.flatMap((playerPoints) => {
|
|
1014
|
+
const ordered = [...playerPoints].sort((left, right) => left.event.timeSeconds - right.event.timeSeconds ||
|
|
1015
|
+
left.event.sourceEventId.localeCompare(right.event.sourceEventId));
|
|
1016
|
+
return ordered.slice(1).flatMap((end, index) => {
|
|
1017
|
+
const start = ordered[index];
|
|
1018
|
+
const elapsed = end.event.timeSeconds - start.event.timeSeconds;
|
|
1019
|
+
if (elapsed <= 0 || elapsed > TACTICAL_MOVEMENT_MAX_GAP_SECONDS)
|
|
1020
|
+
return [];
|
|
1021
|
+
const distance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
1022
|
+
return Number.isFinite(distance)
|
|
1023
|
+
? [
|
|
1024
|
+
{
|
|
1025
|
+
distance,
|
|
1026
|
+
elapsed,
|
|
1027
|
+
refs: [
|
|
1028
|
+
eventRef(matchId, start.event, 'movement-start'),
|
|
1029
|
+
eventRef(matchId, end.event, 'movement-end'),
|
|
1030
|
+
],
|
|
1031
|
+
},
|
|
1032
|
+
]
|
|
1033
|
+
: [];
|
|
1034
|
+
});
|
|
1035
|
+
})
|
|
1036
|
+
.sort((left, right) => right.distance / right.elapsed - left.distance / left.elapsed);
|
|
1037
|
+
const elapsed = sum(segments.map((item) => item.elapsed));
|
|
1038
|
+
const distance = sum(segments.map((item) => item.distance));
|
|
1039
|
+
return makeCandidate({
|
|
1040
|
+
matchId,
|
|
1041
|
+
ruleId: 'rotation.sampled-movement',
|
|
1042
|
+
category: 'rotation',
|
|
1043
|
+
title: '本队遥测位置变化',
|
|
1044
|
+
statement: segments.length
|
|
1045
|
+
? `${segments.length} 个有效相邻位置段,累计平面位移 ${distance.toFixed(1)},累计间隔 ${elapsed.toFixed(1)} 秒。`
|
|
1046
|
+
: '没有满足时间和坐标条件的相邻本队位置段。',
|
|
1047
|
+
numerator: segments.length ? distance : null,
|
|
1048
|
+
denominator: elapsed || null,
|
|
1049
|
+
sampleSize: segments.length,
|
|
1050
|
+
value: elapsed ? distance / elapsed : null,
|
|
1051
|
+
unit: '遥测坐标单位 / 秒',
|
|
1052
|
+
support: segments.slice(0, TACTICAL_MAX_EVIDENCE_REFS).flatMap((item) => item.refs),
|
|
1053
|
+
counterexamples: [...segments]
|
|
1054
|
+
.reverse()
|
|
1055
|
+
.slice(0, TACTICAL_MAX_EVIDENCE_REFS)
|
|
1056
|
+
.flatMap((item) => item.refs),
|
|
1057
|
+
complete: segments.length > 0,
|
|
1058
|
+
reasons: segments.length ? undefined : ['缺少连续有效的位置采样;不把缺席解释为静止。'],
|
|
1059
|
+
assumptions: [
|
|
1060
|
+
`相邻 POSITION 时间差须为 0–${TACTICAL_MOVEMENT_MAX_GAP_SECONDS} 秒,使用 X/Y 平面距离;不外推未采样路径。`,
|
|
1061
|
+
],
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
function analyzeBlueZoneDamage(matchId, events, trackedPlayerIds) {
|
|
1065
|
+
const damageEvents = events.filter((event) => event.type === 'DAMAGE' &&
|
|
1066
|
+
event.phase === 'in_match' &&
|
|
1067
|
+
event.victimId &&
|
|
1068
|
+
trackedPlayerIds.has(event.victimId) &&
|
|
1069
|
+
event.damage > 0);
|
|
1070
|
+
const withReason = damageEvents.filter((event) => event.damageReason?.trim());
|
|
1071
|
+
const blueZone = withReason.filter((event) => /blue\s*zone|bluezone|poison(?:\s*gas|zone)?/iu.test(event.damageReason ?? ''));
|
|
1072
|
+
return makeCandidate({
|
|
1073
|
+
matchId,
|
|
1074
|
+
ruleId: 'rotation.explicit-blue-zone-damage',
|
|
1075
|
+
category: 'rotation',
|
|
1076
|
+
title: '有明示来源的蓝区伤害',
|
|
1077
|
+
statement: withReason.length
|
|
1078
|
+
? `${blueZone.length}/${withReason.length} 条带有 damageReason 的本队受伤事件明确匹配蓝区/毒气原因。`
|
|
1079
|
+
: 'damageReason 没有可用于识别蓝区/毒气来源的事件,结果未知。',
|
|
1080
|
+
numerator: withReason.length ? blueZone.length : null,
|
|
1081
|
+
denominator: withReason.length ? withReason.length : null,
|
|
1082
|
+
sampleSize: withReason.length,
|
|
1083
|
+
support: blueZone.map((event) => eventRef(matchId, event, 'explicit-blue-zone-damage')),
|
|
1084
|
+
counterexamples: withReason
|
|
1085
|
+
.filter((event) => !/blue\s*zone|bluezone|poison(?:\s*gas|zone)?/iu.test(event.damageReason ?? ''))
|
|
1086
|
+
.map((event) => eventRef(matchId, event, 'explicit-other-damage-reason')),
|
|
1087
|
+
complete: damageEvents.length > 0 && withReason.length === damageEvents.length,
|
|
1088
|
+
reasons: !damageEvents.length
|
|
1089
|
+
? ['当前队员没有正伤害受击事件作为评估机会。']
|
|
1090
|
+
: !withReason.length
|
|
1091
|
+
? ['Domain telemetry 没有提供 damageReason;damage=0/unknown 不计作蓝区伤害。']
|
|
1092
|
+
: withReason.length < damageEvents.length
|
|
1093
|
+
? [
|
|
1094
|
+
`${damageEvents.length - withReason.length} 条本队受击事件缺少 damageReason,未用于来源分类。`,
|
|
1095
|
+
]
|
|
1096
|
+
: undefined,
|
|
1097
|
+
assumptions: [
|
|
1098
|
+
'只有 domain-normalized damageReason 明确含 BlueZone/Poison 才计为蓝区伤害;isInBlueZone 标志本身不证明伤害来源。',
|
|
1099
|
+
],
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
function mean(values) {
|
|
1103
|
+
return values.length ? sum(values) / values.length : 0;
|
|
1104
|
+
}
|
|
1105
|
+
function sum(values) {
|
|
1106
|
+
return values.reduce((total, value) => total + value, 0);
|
|
1107
|
+
}
|
|
1108
|
+
export function analyzeTacticalMatch(input) {
|
|
1109
|
+
const matchId = input.match.matchId;
|
|
1110
|
+
const trackedPlayerIds = new Set(input.trackedPlayerIds);
|
|
1111
|
+
const trackedPlayerRefs = new Set(input.match.players
|
|
1112
|
+
.filter((player) => trackedPlayerIds.has(player.accountId))
|
|
1113
|
+
.map((player) => battlePlayerRef(matchId, player.accountId)));
|
|
1114
|
+
const events = uniqueEvents(input.telemetry.events).filter((event) => event.phase === 'in_match');
|
|
1115
|
+
const eventById = new Map(events.map((event) => [event.sourceEventId, event]));
|
|
1116
|
+
const episodes = input.reconstruction.episodes;
|
|
1117
|
+
const positions = pointsByBucket(matchId, input.telemetry, trackedPlayerIds);
|
|
1118
|
+
const distances = analyzeDistances(matchId, input.match, episodes, trackedPlayerIds, positions.buckets, positions.points, eventById);
|
|
1119
|
+
const trackedTeamIds = trackedTeamIdsFor(events, trackedPlayerIds);
|
|
1120
|
+
const trackedTeamRefs = new Set([...trackedTeamIds].map((teamId) => battleTeamRef(matchId, teamId)));
|
|
1121
|
+
const sourceComplete = input.telemetry.diagnostics.unrecognizedDomainEventCount === 0 &&
|
|
1122
|
+
input.telemetry.diagnostics.untimedBattleEventCount === 0;
|
|
1123
|
+
const social = analyzeSocial(matchId, input.match, events, episodes, trackedPlayerIds, trackedTeamIds, sourceComplete);
|
|
1124
|
+
const candidates = [
|
|
1125
|
+
analyzeFirstKnocks(matchId, episodes, trackedPlayerRefs, eventById, trackedTeamRefs),
|
|
1126
|
+
...analyzeBattleDynamics(matchId, input.reconstruction.battles, events, trackedTeamIds),
|
|
1127
|
+
analyzeFollowUps(matchId, episodes, events, trackedPlayerRefs, trackedPlayerIds, trackedTeamIds, eventById),
|
|
1128
|
+
analyzeAdvantageConversion(matchId, episodes, trackedPlayerRefs),
|
|
1129
|
+
distances.sampled,
|
|
1130
|
+
distances.onset,
|
|
1131
|
+
distances.isolation,
|
|
1132
|
+
analyzeResponseWindows(matchId, events, trackedPlayerIds, trackedTeamIdsFor(events, trackedPlayerIds)),
|
|
1133
|
+
social.friendlyFire,
|
|
1134
|
+
social.rescue,
|
|
1135
|
+
...social.pairs,
|
|
1136
|
+
social.playful,
|
|
1137
|
+
analyzeCircleWindows(matchId, episodes, input.reconstruction, eventById),
|
|
1138
|
+
analyzeMovement(matchId, positions.points),
|
|
1139
|
+
analyzeBlueZoneDamage(matchId, events, trackedPlayerIds),
|
|
1140
|
+
];
|
|
1141
|
+
const incompleteTeamEventCount = events.filter((event) => (event.actorId && trackedPlayerIds.has(event.actorId) && !event.actorTeamId) ||
|
|
1142
|
+
(event.victimId && trackedPlayerIds.has(event.victimId) && !event.victimTeamId)).length;
|
|
1143
|
+
return {
|
|
1144
|
+
schemaVersion: TACTICAL_ANALYSIS_SCHEMA_VERSION,
|
|
1145
|
+
ruleVersion: TACTICAL_RULE_VERSION,
|
|
1146
|
+
matchId,
|
|
1147
|
+
candidates,
|
|
1148
|
+
pairObservations: social.observations,
|
|
1149
|
+
diagnostics: {
|
|
1150
|
+
trackedPlayerCount: trackedPlayerIds.size,
|
|
1151
|
+
episodeCount: episodes.length,
|
|
1152
|
+
battleGroupCount: input.reconstruction.battles.length,
|
|
1153
|
+
multiTeamBattleCount: input.reconstruction.battles.filter((battle) => battle.teamRefs.length >= 3).length,
|
|
1154
|
+
advantageWindowCount: input.reconstruction.battles.reduce((sum, battle) => sum + battle.advantageWindows.length, 0),
|
|
1155
|
+
observedRosterOnlyBattleCount: input.reconstruction.battles.length,
|
|
1156
|
+
positionEventCount: positions.points.length,
|
|
1157
|
+
incompleteTeamEventCount,
|
|
1158
|
+
unknownTimestampEventCount: input.telemetry.events.filter((event) => event.phase === 'in_match' && event.timeSeconds === null).length,
|
|
1159
|
+
unmatchedBattleSourceCount: input.telemetry.diagnostics.unrecognizedDomainEventCount,
|
|
1160
|
+
},
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
export function buildFrequentPairCandidates(analyses) {
|
|
1164
|
+
const byPair = new Map();
|
|
1165
|
+
for (const analysis of analyses) {
|
|
1166
|
+
for (const observation of analysis.pairObservations) {
|
|
1167
|
+
if (!observation.eligible)
|
|
1168
|
+
continue;
|
|
1169
|
+
byPair.set(observation.pairRef, [...(byPair.get(observation.pairRef) ?? []), observation]);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return [...byPair.values()]
|
|
1173
|
+
.map((observations) => {
|
|
1174
|
+
const first = observations[0];
|
|
1175
|
+
const sharedMatches = observations.filter((observation) => observation.sharedEngagementCount > 0);
|
|
1176
|
+
const support = sharedMatches.flatMap((observation) => observation.supportEvents);
|
|
1177
|
+
const counterexamples = observations.flatMap((observation) => observation.counterexamples);
|
|
1178
|
+
const enoughMatches = sharedMatches.length >= TACTICAL_PAIR_MIN_SHARED_MATCHES;
|
|
1179
|
+
const incompleteSourceMatches = observations.filter((observation) => !observation.sourceComplete).length;
|
|
1180
|
+
return makeCandidate({
|
|
1181
|
+
matchId: `dataset-${digest(observations
|
|
1182
|
+
.map((item) => item.matchId)
|
|
1183
|
+
.sort()
|
|
1184
|
+
.join('\u001f'), 16)}`,
|
|
1185
|
+
ruleId: 'social.recurrent-shared-engagement-pair',
|
|
1186
|
+
category: 'social',
|
|
1187
|
+
title: '常搭对候选(重复共同交战)',
|
|
1188
|
+
statement: `${sharedMatches.length}/${observations.length} 场可分析比赛观察到这对成员共同参与至少一个交战;${enoughMatches ? '达到重复候选门槛' : `不足 ${TACTICAL_PAIR_MIN_SHARED_MATCHES} 场,不形成常搭候选`}。这不表示偏好或关系。`,
|
|
1189
|
+
numerator: sharedMatches.length,
|
|
1190
|
+
denominator: observations.length,
|
|
1191
|
+
sampleSize: observations.length,
|
|
1192
|
+
support,
|
|
1193
|
+
counterexamples,
|
|
1194
|
+
complete: incompleteSourceMatches === 0,
|
|
1195
|
+
statusOverride: observations.length < TACTICAL_PAIR_MIN_SHARED_MATCHES
|
|
1196
|
+
? 'insufficient_data'
|
|
1197
|
+
: enoughMatches
|
|
1198
|
+
? 'observed'
|
|
1199
|
+
: 'not_observed',
|
|
1200
|
+
reasons: [
|
|
1201
|
+
...(observations.length < TACTICAL_PAIR_MIN_SHARED_MATCHES
|
|
1202
|
+
? [
|
|
1203
|
+
`只有 ${observations.length} 场 eligible match,低于规则最低 ${TACTICAL_PAIR_MIN_SHARED_MATCHES} 场;不评级为常搭对。`,
|
|
1204
|
+
]
|
|
1205
|
+
: []),
|
|
1206
|
+
...(incompleteSourceMatches
|
|
1207
|
+
? [
|
|
1208
|
+
`${incompleteSourceMatches}/${observations.length} 场原始事件存在未识别或无时间行,候选质量降级。`,
|
|
1209
|
+
]
|
|
1210
|
+
: []),
|
|
1211
|
+
],
|
|
1212
|
+
assumptions: [
|
|
1213
|
+
`只有同一对 roster 成员同时出现在同一 R1 episode 才计共同交战;至少 ${TACTICAL_PAIR_MIN_SHARED_MATCHES} 场不同比赛有支持事件才形成候选。`,
|
|
1214
|
+
],
|
|
1215
|
+
pairRefs: first.pairRefs,
|
|
1216
|
+
});
|
|
1217
|
+
})
|
|
1218
|
+
.sort((left, right) => (right.metric.numerator ?? 0) - (left.metric.numerator ?? 0) ||
|
|
1219
|
+
left.id.localeCompare(right.id));
|
|
1220
|
+
}
|
|
1221
|
+
//# sourceMappingURL=tactics.js.map
|