@axiapps/bridge-metrics 0.1.0 → 0.2.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/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(src_exports, {
23
23
  DEFAULT_DISRUPTION_METHOD: () => DEFAULT_DISRUPTION_METHOD,
24
24
  METRICS_SPEC: () => METRICS_SPEC,
25
25
  NON_DAMAGING_CONDITIONS: () => NON_DAMAGING_CONDITIONS,
26
+ OUT_OF_POSITION: () => OUT_OF_POSITION,
26
27
  PROFESSION_COLORS: () => PROFESSION_COLORS,
27
28
  ROLLUP_SOURCES_VERSION: () => ROLLUP_SOURCES_VERSION,
28
29
  ReportSchemaError: () => ReportSchemaError,
@@ -30,12 +31,14 @@ __export(src_exports, {
30
31
  applySquadStabilityGeneration: () => applySquadStabilityGeneration,
31
32
  buildConditionIconMap: () => buildConditionIconMap,
32
33
  buildRollupData: () => buildRollupData,
34
+ classifyDegree: () => classifyDegree,
33
35
  compareRunSets: () => compareRunSets,
34
36
  computeDownContribution: () => computeDownContribution,
35
37
  computeIncomingDisruptions: () => computeIncomingDisruptions,
36
38
  computeOutgoingConditions: () => computeOutgoingConditions,
37
39
  computeOutgoingCrowdControl: () => computeOutgoingCrowdControl,
38
40
  computePlayerAggregation: () => computePlayerAggregation,
41
+ computePositioning: () => computePositioning,
39
42
  computeSquadBarrier: () => computeSquadBarrier,
40
43
  computeSquadHealing: () => computeSquadHealing,
41
44
  createPlayerAggregationAccumulators: () => createPlayerAggregationAccumulators,
@@ -2835,11 +2838,277 @@ var compareRunSets = (a, b) => {
2835
2838
  })
2836
2839
  };
2837
2840
  };
2841
+
2842
+ // src/positioning.ts
2843
+ var OUT_OF_POSITION = 1200;
2844
+ var clamp = (val, min, max) => Math.max(min, Math.min(max, val));
2845
+ var squadOf = (r) => (r.details?.players ?? []).filter((p) => !p?.notInSquad);
2846
+ function linkedDeathHits(player, tagPositions, pollingRate) {
2847
+ const replay = player?.combatReplayData;
2848
+ if (!replay || !Array.isArray(replay.dead) || !Array.isArray(replay.down)) return [];
2849
+ const playerPositions = replay.positions;
2850
+ if (!Array.isArray(playerPositions) || playerPositions.length === 0) return [];
2851
+ const playerStart = Number(replay.start ?? 0);
2852
+ const playerOffset = Math.floor(playerStart / pollingRate);
2853
+ const deadSet = /* @__PURE__ */ new Set();
2854
+ for (const entry of replay.dead) {
2855
+ if (Array.isArray(entry) && Number.isFinite(Number(entry[0])) && Number(entry[0]) > 0) {
2856
+ deadSet.add(Number(entry[0]));
2857
+ }
2858
+ }
2859
+ const hits = [];
2860
+ for (const entry of replay.down) {
2861
+ if (!Array.isArray(entry)) continue;
2862
+ const downStartMs = Number(entry[0]);
2863
+ const linkedDeathMs = Number(entry[1]);
2864
+ if (!Number.isFinite(downStartMs) || downStartMs < 0) continue;
2865
+ if (!deadSet.has(linkedDeathMs)) continue;
2866
+ const pollIndex = Math.floor(downStartMs / pollingRate);
2867
+ const playerIdx = clamp(pollIndex - playerOffset, 0, playerPositions.length - 1);
2868
+ const tagIdx = clamp(pollIndex, 0, tagPositions.length - 1);
2869
+ const [px, py] = playerPositions[playerIdx];
2870
+ const [tx, ty] = tagPositions[tagIdx];
2871
+ hits.push({ px, py, tx, ty, downStartMs });
2872
+ }
2873
+ return hits;
2874
+ }
2875
+ function classifyDegree(report) {
2876
+ const squad = squadOf(report);
2877
+ const meta = report.details?.combatReplayMetaData ?? {};
2878
+ const commander = squad.find((p) => p?.hasCommanderTag);
2879
+ const tagPositions = commander?.combatReplayData?.positions ?? [];
2880
+ if (commander && tagPositions.length > 0 && (meta.pollingRate ?? 0) > 0 && (meta.inchToPixel ?? 0) > 0) return "full";
2881
+ if (squad.some((p) => typeof p?.statsAll?.[0]?.distToCom === "number")) return "coarse";
2882
+ return "none";
2883
+ }
2884
+ function computePositioning(report) {
2885
+ const degree = classifyDegree(report);
2886
+ const squad = squadOf(report);
2887
+ const meta = report.details?.combatReplayMetaData ?? {};
2888
+ const pollingRate = (meta.pollingRate ?? 0) > 0 ? meta.pollingRate : 0;
2889
+ const inchToPixel = (meta.inchToPixel ?? 0) > 0 ? meta.inchToPixel : 0;
2890
+ const commander = squad.find((p) => p?.hasCommanderTag);
2891
+ const tagPositions = commander?.combatReplayData?.positions ?? [];
2892
+ const replayUsable = !!commander && tagPositions.length > 0 && pollingRate > 0 && inchToPixel > 0;
2893
+ const perPlayerMap = /* @__PURE__ */ new Map();
2894
+ const outOfPositionDeaths = [];
2895
+ if (replayUsable) {
2896
+ for (const player of squad) {
2897
+ const account = player?.account ?? "Unknown";
2898
+ const isCommanderPlayer = !!player?.hasCommanderTag;
2899
+ const playerPositions = player?.combatReplayData?.positions;
2900
+ if (!Array.isArray(playerPositions) || playerPositions.length === 0) continue;
2901
+ const playerStart = Number(player?.combatReplayData?.start ?? 0);
2902
+ const playerOffset = Math.floor(playerStart / pollingRate);
2903
+ const samples = [];
2904
+ for (let i = 0; i < playerPositions.length; i++) {
2905
+ const tagIdx = clamp(i + playerOffset, 0, tagPositions.length - 1);
2906
+ const [px, py] = playerPositions[i];
2907
+ const [tx, ty] = tagPositions[tagIdx];
2908
+ const dist = isCommanderPlayer ? 0 : Math.hypot(px - tx, py - ty) / inchToPixel;
2909
+ samples.push(dist);
2910
+ }
2911
+ if (!isCommanderPlayer) {
2912
+ const existing = perPlayerMap.get(account);
2913
+ if (existing) {
2914
+ for (const s of samples) existing.push(s);
2915
+ } else {
2916
+ perPlayerMap.set(account, samples);
2917
+ }
2918
+ }
2919
+ if (isCommanderPlayer) continue;
2920
+ for (const { px, py, tx, ty, downStartMs } of linkedDeathHits(player, tagPositions, pollingRate)) {
2921
+ const distAtDown = Math.round(Math.hypot(px - tx, py - ty) / inchToPixel);
2922
+ if (distAtDown > OUT_OF_POSITION) {
2923
+ outOfPositionDeaths.push({ account, distAtDown, atSec: Math.round(downStartMs / 1e3) });
2924
+ }
2925
+ }
2926
+ }
2927
+ }
2928
+ const perPlayer = [];
2929
+ if (replayUsable) {
2930
+ for (const [account, samples] of perPlayerMap) {
2931
+ if (samples.length === 0) continue;
2932
+ const avg = samples.reduce((s, v) => s + v, 0) / samples.length;
2933
+ const peak = Math.max(...samples);
2934
+ perPlayer.push({ account, avgDistToTag: Math.round(avg), peakDistToTag: Math.round(peak) });
2935
+ }
2936
+ perPlayer.sort((a, b) => b.peakDistToTag - a.peakDistToTag);
2937
+ } else if (degree === "coarse") {
2938
+ for (const p of squad) {
2939
+ const account = p?.account;
2940
+ if (!account) continue;
2941
+ const distToCom = p?.statsAll?.[0]?.distToCom;
2942
+ if (typeof distToCom !== "number") continue;
2943
+ perPlayer.push({ account, avgDistToTag: distToCom, peakDistToTag: distToCom });
2944
+ }
2945
+ perPlayer.sort((a, b) => b.peakDistToTag - a.peakDistToTag);
2946
+ }
2947
+ if (degree !== "full" || !replayUsable) {
2948
+ return { degree, perPlayer, outOfPositionDeaths, squad: null, commander: null, deathClusters: [], figure: void 0 };
2949
+ }
2950
+ const squadNonCmdr = squad.filter((p) => !p?.hasCommanderTag && Array.isArray(p?.combatReplayData?.positions) && (p.combatReplayData.positions.length ?? 0) > 0);
2951
+ const numTicks = tagPositions.length;
2952
+ let spreadSum = 0;
2953
+ let peakSpreadValue = 0;
2954
+ let peakSpreadAtSec = 0;
2955
+ let tagToCentroidSum = 0;
2956
+ let peakLeadValue = 0;
2957
+ let peakLeadAtSec = 0;
2958
+ const deathCoords = [];
2959
+ for (const player of squad) {
2960
+ for (const { px, py } of linkedDeathHits(player, tagPositions, pollingRate)) {
2961
+ deathCoords.push([px, py]);
2962
+ }
2963
+ }
2964
+ for (let i = 0; i < numTicks; i++) {
2965
+ const atSec = i * pollingRate / 1e3;
2966
+ const [tx, ty] = tagPositions[i];
2967
+ if (squadNonCmdr.length > 0) {
2968
+ let spreadAtTick = 0;
2969
+ let centroidX = 0;
2970
+ let centroidY = 0;
2971
+ for (const player of squadNonCmdr) {
2972
+ const positions = player.combatReplayData.positions;
2973
+ const playerStart = Number(player.combatReplayData?.start ?? 0);
2974
+ const playerOffset = Math.floor(playerStart / pollingRate);
2975
+ const pIdx = clamp(i - playerOffset, 0, positions.length - 1);
2976
+ const [px, py] = positions[pIdx];
2977
+ spreadAtTick += Math.hypot(px - tx, py - ty) / inchToPixel;
2978
+ centroidX += px;
2979
+ centroidY += py;
2980
+ }
2981
+ const avgSpreadAtTick = spreadAtTick / squadNonCmdr.length;
2982
+ spreadSum += avgSpreadAtTick;
2983
+ if (avgSpreadAtTick > peakSpreadValue) {
2984
+ peakSpreadValue = avgSpreadAtTick;
2985
+ peakSpreadAtSec = atSec;
2986
+ }
2987
+ centroidX /= squadNonCmdr.length;
2988
+ centroidY /= squadNonCmdr.length;
2989
+ const tagToCentroidDist = Math.hypot(tx - centroidX, ty - centroidY) / inchToPixel;
2990
+ tagToCentroidSum += tagToCentroidDist;
2991
+ if (tagToCentroidDist > peakLeadValue) {
2992
+ peakLeadValue = tagToCentroidDist;
2993
+ peakLeadAtSec = atSec;
2994
+ }
2995
+ }
2996
+ }
2997
+ const avgSpread = numTicks > 0 ? spreadSum / numTicks : 0;
2998
+ const peakAvgRatio = avgSpread > 0 ? peakSpreadValue / avgSpread : 1;
2999
+ const cohesionNote = peakAvgRatio > 2.5 ? "tight then scattered" : "held together";
3000
+ const squadFollowLag = numTicks > 0 ? tagToCentroidSum / numTicks : 0;
3001
+ const CLUSTER_CELL = 150;
3002
+ const cellMap = /* @__PURE__ */ new Map();
3003
+ for (const [x, y] of deathCoords) {
3004
+ const cellX = Math.floor(x / CLUSTER_CELL);
3005
+ const cellY = Math.floor(y / CLUSTER_CELL);
3006
+ const key = `${cellX},${cellY}`;
3007
+ const existing = cellMap.get(key);
3008
+ if (existing) {
3009
+ existing.sumX += x;
3010
+ existing.sumY += y;
3011
+ existing.count++;
3012
+ } else {
3013
+ cellMap.set(key, { sumX: x, sumY: y, count: 1 });
3014
+ }
3015
+ }
3016
+ const deathClusters = Array.from(cellMap.values()).map(({ sumX, sumY, count }) => ({ x: sumX / count, y: sumY / count, count })).sort((a, b) => b.count - a.count).slice(0, 6);
3017
+ const stride = Math.ceil(1e3 / pollingRate);
3018
+ const tagPath = [];
3019
+ for (let i = 0; i < tagPositions.length; i += stride) {
3020
+ tagPath.push(tagPositions[i]);
3021
+ }
3022
+ const downCoords = [];
3023
+ for (const player of squad) {
3024
+ const replay = player?.combatReplayData;
3025
+ if (!replay || !Array.isArray(replay.down) || !Array.isArray(replay.positions)) continue;
3026
+ const playerStart = Number(replay.start ?? 0);
3027
+ const playerOffset = Math.floor(playerStart / pollingRate);
3028
+ for (const entry of replay.down) {
3029
+ if (!Array.isArray(entry)) continue;
3030
+ const downStartMs = Number(entry[0]);
3031
+ if (!Number.isFinite(downStartMs) || downStartMs < 0) continue;
3032
+ const pollIndex = Math.floor(downStartMs / pollingRate);
3033
+ const playerIdx = clamp(pollIndex - playerOffset, 0, replay.positions.length - 1);
3034
+ const [px, py] = replay.positions[playerIdx];
3035
+ downCoords.push([px, py]);
3036
+ }
3037
+ }
3038
+ const spreadTimeline = [];
3039
+ let sumMassX = 0;
3040
+ let sumMassY = 0;
3041
+ let massCount = 0;
3042
+ let minX = Infinity;
3043
+ let maxX = -Infinity;
3044
+ let minY = Infinity;
3045
+ let maxY = -Infinity;
3046
+ for (let i = 0; i < numTicks; i += stride) {
3047
+ const atSec = i * pollingRate / 1e3;
3048
+ const [tx, ty] = tagPositions[i];
3049
+ let spreadAtTick = 0;
3050
+ let centroidX = 0;
3051
+ let centroidY = 0;
3052
+ for (const player of squadNonCmdr) {
3053
+ const positions = player.combatReplayData.positions;
3054
+ const playerStart = Number(player.combatReplayData?.start ?? 0);
3055
+ const playerOffset = Math.floor(playerStart / pollingRate);
3056
+ const pIdx = clamp(i - playerOffset, 0, positions.length - 1);
3057
+ const [px, py] = positions[pIdx];
3058
+ spreadAtTick += Math.hypot(px - tx, py - ty) / inchToPixel;
3059
+ centroidX += px;
3060
+ centroidY += py;
3061
+ }
3062
+ if (squadNonCmdr.length > 0) {
3063
+ centroidX /= squadNonCmdr.length;
3064
+ centroidY /= squadNonCmdr.length;
3065
+ spreadTimeline.push([atSec, Math.round(spreadAtTick / squadNonCmdr.length)]);
3066
+ sumMassX += centroidX;
3067
+ sumMassY += centroidY;
3068
+ minX = Math.min(minX, centroidX);
3069
+ maxX = Math.max(maxX, centroidX);
3070
+ minY = Math.min(minY, centroidY);
3071
+ maxY = Math.max(maxY, centroidY);
3072
+ massCount++;
3073
+ }
3074
+ }
3075
+ const massX = massCount > 0 ? sumMassX / massCount : 0;
3076
+ const massY = massCount > 0 ? sumMassY / massCount : 0;
3077
+ const massR = massCount > 0 ? Math.hypot(maxX - minX, maxY - minY) / 2 : 0;
3078
+ const sizes = meta.sizes ?? [0, 0];
3079
+ const figure = {
3080
+ map: { sizes, inchToPixel },
3081
+ tagPath,
3082
+ squadMass: { x: Math.round(massX), y: Math.round(massY), r: Math.round(massR) },
3083
+ deaths: deathCoords,
3084
+ downs: downCoords,
3085
+ spread: spreadTimeline,
3086
+ peakSpread: Math.round(peakSpreadValue)
3087
+ };
3088
+ return {
3089
+ degree,
3090
+ perPlayer,
3091
+ outOfPositionDeaths,
3092
+ squad: {
3093
+ avgSpread: Math.round(avgSpread),
3094
+ peakSpread: { value: Math.round(peakSpreadValue), atSec: peakSpreadAtSec },
3095
+ cohesionNote
3096
+ },
3097
+ commander: {
3098
+ account: commander?.account ?? "Unknown",
3099
+ peakLeadFromSquad: { value: Math.round(peakLeadValue), atSec: peakLeadAtSec },
3100
+ squadFollowLag: Math.round(squadFollowLag)
3101
+ },
3102
+ deathClusters,
3103
+ figure
3104
+ };
3105
+ }
2838
3106
  // Annotate the CommonJS export names for ESM import in node:
2839
3107
  0 && (module.exports = {
2840
3108
  DEFAULT_DISRUPTION_METHOD,
2841
3109
  METRICS_SPEC,
2842
3110
  NON_DAMAGING_CONDITIONS,
3111
+ OUT_OF_POSITION,
2843
3112
  PROFESSION_COLORS,
2844
3113
  ROLLUP_SOURCES_VERSION,
2845
3114
  ReportSchemaError,
@@ -2847,12 +3116,14 @@ var compareRunSets = (a, b) => {
2847
3116
  applySquadStabilityGeneration,
2848
3117
  buildConditionIconMap,
2849
3118
  buildRollupData,
3119
+ classifyDegree,
2850
3120
  compareRunSets,
2851
3121
  computeDownContribution,
2852
3122
  computeIncomingDisruptions,
2853
3123
  computeOutgoingConditions,
2854
3124
  computeOutgoingCrowdControl,
2855
3125
  computePlayerAggregation,
3126
+ computePositioning,
2856
3127
  computeSquadBarrier,
2857
3128
  computeSquadHealing,
2858
3129
  createPlayerAggregationAccumulators,
package/dist/index.d.cts CHANGED
@@ -11,3 +11,99 @@ export { PlayerRoleClassification, RoleClassificationFactor } from './roles.cjs'
11
11
  export { isResUtilitySkill } from './resUtility.cjs';
12
12
  export { parseTimestamp as parseFightTimestamp, resolveFightTimestamp } from './timestampUtils.cjs';
13
13
  export { PlayerAggregate, ReportSchemaError, RunPlayerSummary, RunSetComparison, RunSummary, aggregatePlayers, compareRunSets, extractRunSummary } from './reportMetrics.cjs';
14
+
15
+ type ReplayDegree = 'full' | 'coarse' | 'none';
16
+ type AnyPlayer = {
17
+ notInSquad?: boolean;
18
+ hasCommanderTag?: boolean;
19
+ account?: string;
20
+ profession?: string;
21
+ statsAll?: Array<{
22
+ distToCom?: number;
23
+ stackDist?: number;
24
+ }>;
25
+ combatReplayData?: {
26
+ positions?: Array<[number, number]>;
27
+ dead?: Array<[number, number]>;
28
+ down?: Array<[number, number]>;
29
+ start?: number;
30
+ };
31
+ };
32
+ type ParsedReport = {
33
+ details?: {
34
+ players?: AnyPlayer[];
35
+ combatReplayMetaData?: {
36
+ pollingRate?: number;
37
+ inchToPixel?: number;
38
+ sizes?: [number, number];
39
+ };
40
+ durationMS?: number;
41
+ };
42
+ };
43
+ type PerPlayerDistance = {
44
+ account: string;
45
+ avgDistToTag: number;
46
+ peakDistToTag: number;
47
+ };
48
+ type OutOfPositionDeath = {
49
+ account: string;
50
+ distAtDown: number;
51
+ atSec: number;
52
+ };
53
+ type SquadCohesion = {
54
+ avgSpread: number;
55
+ peakSpread: {
56
+ value: number;
57
+ atSec: number;
58
+ };
59
+ cohesionNote: string;
60
+ };
61
+ type CommanderOverextension = {
62
+ account: string;
63
+ peakLeadFromSquad: {
64
+ value: number;
65
+ atSec: number;
66
+ };
67
+ /** v1 definition: mean per-tick distance from commander to squad centroid */
68
+ squadFollowLag: number;
69
+ };
70
+ type DeathCluster = {
71
+ x: number;
72
+ y: number;
73
+ count: number;
74
+ };
75
+ type PositioningFigure = {
76
+ map: {
77
+ sizes: [number, number];
78
+ inchToPixel: number;
79
+ };
80
+ /** Down-sampled tag path: ~1 point/sec (stride = Math.ceil(1000/pollingRate)) */
81
+ tagPath: Array<[number, number]>;
82
+ /** Approximate bounding circle of the squad centroid over time */
83
+ squadMass: {
84
+ x: number;
85
+ y: number;
86
+ r: number;
87
+ };
88
+ /** Death locations (reused from deathClusters source) */
89
+ deaths: Array<[number, number]>;
90
+ /** Down positions */
91
+ downs: Array<[number, number]>;
92
+ /** [[sec, spreadValue]] down-sampled to ~1 point/sec */
93
+ spread: Array<[number, number]>;
94
+ peakSpread: number;
95
+ };
96
+ type PositioningSummary = {
97
+ degree: ReplayDegree;
98
+ perPlayer: PerPlayerDistance[];
99
+ outOfPositionDeaths: OutOfPositionDeath[];
100
+ squad: SquadCohesion | null;
101
+ commander: CommanderOverextension | null;
102
+ deathClusters: DeathCluster[];
103
+ figure: PositioningFigure | undefined;
104
+ };
105
+ declare const OUT_OF_POSITION = 1200;
106
+ declare function classifyDegree(report: ParsedReport): ReplayDegree;
107
+ declare function computePositioning(report: ParsedReport): PositioningSummary;
108
+
109
+ export { type CommanderOverextension, type DeathCluster, OUT_OF_POSITION, type OutOfPositionDeath, type ParsedReport, type PerPlayerDistance, type PositioningFigure, type PositioningSummary, type ReplayDegree, type SquadCohesion, classifyDegree, computePositioning };
package/dist/index.d.ts CHANGED
@@ -11,3 +11,99 @@ export { PlayerRoleClassification, RoleClassificationFactor } from './roles.js';
11
11
  export { isResUtilitySkill } from './resUtility.js';
12
12
  export { parseTimestamp as parseFightTimestamp, resolveFightTimestamp } from './timestampUtils.js';
13
13
  export { PlayerAggregate, ReportSchemaError, RunPlayerSummary, RunSetComparison, RunSummary, aggregatePlayers, compareRunSets, extractRunSummary } from './reportMetrics.js';
14
+
15
+ type ReplayDegree = 'full' | 'coarse' | 'none';
16
+ type AnyPlayer = {
17
+ notInSquad?: boolean;
18
+ hasCommanderTag?: boolean;
19
+ account?: string;
20
+ profession?: string;
21
+ statsAll?: Array<{
22
+ distToCom?: number;
23
+ stackDist?: number;
24
+ }>;
25
+ combatReplayData?: {
26
+ positions?: Array<[number, number]>;
27
+ dead?: Array<[number, number]>;
28
+ down?: Array<[number, number]>;
29
+ start?: number;
30
+ };
31
+ };
32
+ type ParsedReport = {
33
+ details?: {
34
+ players?: AnyPlayer[];
35
+ combatReplayMetaData?: {
36
+ pollingRate?: number;
37
+ inchToPixel?: number;
38
+ sizes?: [number, number];
39
+ };
40
+ durationMS?: number;
41
+ };
42
+ };
43
+ type PerPlayerDistance = {
44
+ account: string;
45
+ avgDistToTag: number;
46
+ peakDistToTag: number;
47
+ };
48
+ type OutOfPositionDeath = {
49
+ account: string;
50
+ distAtDown: number;
51
+ atSec: number;
52
+ };
53
+ type SquadCohesion = {
54
+ avgSpread: number;
55
+ peakSpread: {
56
+ value: number;
57
+ atSec: number;
58
+ };
59
+ cohesionNote: string;
60
+ };
61
+ type CommanderOverextension = {
62
+ account: string;
63
+ peakLeadFromSquad: {
64
+ value: number;
65
+ atSec: number;
66
+ };
67
+ /** v1 definition: mean per-tick distance from commander to squad centroid */
68
+ squadFollowLag: number;
69
+ };
70
+ type DeathCluster = {
71
+ x: number;
72
+ y: number;
73
+ count: number;
74
+ };
75
+ type PositioningFigure = {
76
+ map: {
77
+ sizes: [number, number];
78
+ inchToPixel: number;
79
+ };
80
+ /** Down-sampled tag path: ~1 point/sec (stride = Math.ceil(1000/pollingRate)) */
81
+ tagPath: Array<[number, number]>;
82
+ /** Approximate bounding circle of the squad centroid over time */
83
+ squadMass: {
84
+ x: number;
85
+ y: number;
86
+ r: number;
87
+ };
88
+ /** Death locations (reused from deathClusters source) */
89
+ deaths: Array<[number, number]>;
90
+ /** Down positions */
91
+ downs: Array<[number, number]>;
92
+ /** [[sec, spreadValue]] down-sampled to ~1 point/sec */
93
+ spread: Array<[number, number]>;
94
+ peakSpread: number;
95
+ };
96
+ type PositioningSummary = {
97
+ degree: ReplayDegree;
98
+ perPlayer: PerPlayerDistance[];
99
+ outOfPositionDeaths: OutOfPositionDeath[];
100
+ squad: SquadCohesion | null;
101
+ commander: CommanderOverextension | null;
102
+ deathClusters: DeathCluster[];
103
+ figure: PositioningFigure | undefined;
104
+ };
105
+ declare const OUT_OF_POSITION = 1200;
106
+ declare function classifyDegree(report: ParsedReport): ReplayDegree;
107
+ declare function computePositioning(report: ParsedReport): PositioningSummary;
108
+
109
+ export { type CommanderOverextension, type DeathCluster, OUT_OF_POSITION, type OutOfPositionDeath, type ParsedReport, type PerPlayerDistance, type PositioningFigure, type PositioningSummary, type ReplayDegree, type SquadCohesion, classifyDegree, computePositioning };
package/dist/index.js CHANGED
@@ -86,10 +86,276 @@ import {
86
86
  hexToRgba,
87
87
  toSuperscript
88
88
  } from "./chunk-WW5XFXGC.js";
89
+
90
+ // src/positioning.ts
91
+ var OUT_OF_POSITION = 1200;
92
+ var clamp = (val, min, max) => Math.max(min, Math.min(max, val));
93
+ var squadOf = (r) => (r.details?.players ?? []).filter((p) => !p?.notInSquad);
94
+ function linkedDeathHits(player, tagPositions, pollingRate) {
95
+ const replay = player?.combatReplayData;
96
+ if (!replay || !Array.isArray(replay.dead) || !Array.isArray(replay.down)) return [];
97
+ const playerPositions = replay.positions;
98
+ if (!Array.isArray(playerPositions) || playerPositions.length === 0) return [];
99
+ const playerStart = Number(replay.start ?? 0);
100
+ const playerOffset = Math.floor(playerStart / pollingRate);
101
+ const deadSet = /* @__PURE__ */ new Set();
102
+ for (const entry of replay.dead) {
103
+ if (Array.isArray(entry) && Number.isFinite(Number(entry[0])) && Number(entry[0]) > 0) {
104
+ deadSet.add(Number(entry[0]));
105
+ }
106
+ }
107
+ const hits = [];
108
+ for (const entry of replay.down) {
109
+ if (!Array.isArray(entry)) continue;
110
+ const downStartMs = Number(entry[0]);
111
+ const linkedDeathMs = Number(entry[1]);
112
+ if (!Number.isFinite(downStartMs) || downStartMs < 0) continue;
113
+ if (!deadSet.has(linkedDeathMs)) continue;
114
+ const pollIndex = Math.floor(downStartMs / pollingRate);
115
+ const playerIdx = clamp(pollIndex - playerOffset, 0, playerPositions.length - 1);
116
+ const tagIdx = clamp(pollIndex, 0, tagPositions.length - 1);
117
+ const [px, py] = playerPositions[playerIdx];
118
+ const [tx, ty] = tagPositions[tagIdx];
119
+ hits.push({ px, py, tx, ty, downStartMs });
120
+ }
121
+ return hits;
122
+ }
123
+ function classifyDegree(report) {
124
+ const squad = squadOf(report);
125
+ const meta = report.details?.combatReplayMetaData ?? {};
126
+ const commander = squad.find((p) => p?.hasCommanderTag);
127
+ const tagPositions = commander?.combatReplayData?.positions ?? [];
128
+ if (commander && tagPositions.length > 0 && (meta.pollingRate ?? 0) > 0 && (meta.inchToPixel ?? 0) > 0) return "full";
129
+ if (squad.some((p) => typeof p?.statsAll?.[0]?.distToCom === "number")) return "coarse";
130
+ return "none";
131
+ }
132
+ function computePositioning(report) {
133
+ const degree = classifyDegree(report);
134
+ const squad = squadOf(report);
135
+ const meta = report.details?.combatReplayMetaData ?? {};
136
+ const pollingRate = (meta.pollingRate ?? 0) > 0 ? meta.pollingRate : 0;
137
+ const inchToPixel = (meta.inchToPixel ?? 0) > 0 ? meta.inchToPixel : 0;
138
+ const commander = squad.find((p) => p?.hasCommanderTag);
139
+ const tagPositions = commander?.combatReplayData?.positions ?? [];
140
+ const replayUsable = !!commander && tagPositions.length > 0 && pollingRate > 0 && inchToPixel > 0;
141
+ const perPlayerMap = /* @__PURE__ */ new Map();
142
+ const outOfPositionDeaths = [];
143
+ if (replayUsable) {
144
+ for (const player of squad) {
145
+ const account = player?.account ?? "Unknown";
146
+ const isCommanderPlayer = !!player?.hasCommanderTag;
147
+ const playerPositions = player?.combatReplayData?.positions;
148
+ if (!Array.isArray(playerPositions) || playerPositions.length === 0) continue;
149
+ const playerStart = Number(player?.combatReplayData?.start ?? 0);
150
+ const playerOffset = Math.floor(playerStart / pollingRate);
151
+ const samples = [];
152
+ for (let i = 0; i < playerPositions.length; i++) {
153
+ const tagIdx = clamp(i + playerOffset, 0, tagPositions.length - 1);
154
+ const [px, py] = playerPositions[i];
155
+ const [tx, ty] = tagPositions[tagIdx];
156
+ const dist = isCommanderPlayer ? 0 : Math.hypot(px - tx, py - ty) / inchToPixel;
157
+ samples.push(dist);
158
+ }
159
+ if (!isCommanderPlayer) {
160
+ const existing = perPlayerMap.get(account);
161
+ if (existing) {
162
+ for (const s of samples) existing.push(s);
163
+ } else {
164
+ perPlayerMap.set(account, samples);
165
+ }
166
+ }
167
+ if (isCommanderPlayer) continue;
168
+ for (const { px, py, tx, ty, downStartMs } of linkedDeathHits(player, tagPositions, pollingRate)) {
169
+ const distAtDown = Math.round(Math.hypot(px - tx, py - ty) / inchToPixel);
170
+ if (distAtDown > OUT_OF_POSITION) {
171
+ outOfPositionDeaths.push({ account, distAtDown, atSec: Math.round(downStartMs / 1e3) });
172
+ }
173
+ }
174
+ }
175
+ }
176
+ const perPlayer = [];
177
+ if (replayUsable) {
178
+ for (const [account, samples] of perPlayerMap) {
179
+ if (samples.length === 0) continue;
180
+ const avg = samples.reduce((s, v) => s + v, 0) / samples.length;
181
+ const peak = Math.max(...samples);
182
+ perPlayer.push({ account, avgDistToTag: Math.round(avg), peakDistToTag: Math.round(peak) });
183
+ }
184
+ perPlayer.sort((a, b) => b.peakDistToTag - a.peakDistToTag);
185
+ } else if (degree === "coarse") {
186
+ for (const p of squad) {
187
+ const account = p?.account;
188
+ if (!account) continue;
189
+ const distToCom = p?.statsAll?.[0]?.distToCom;
190
+ if (typeof distToCom !== "number") continue;
191
+ perPlayer.push({ account, avgDistToTag: distToCom, peakDistToTag: distToCom });
192
+ }
193
+ perPlayer.sort((a, b) => b.peakDistToTag - a.peakDistToTag);
194
+ }
195
+ if (degree !== "full" || !replayUsable) {
196
+ return { degree, perPlayer, outOfPositionDeaths, squad: null, commander: null, deathClusters: [], figure: void 0 };
197
+ }
198
+ const squadNonCmdr = squad.filter((p) => !p?.hasCommanderTag && Array.isArray(p?.combatReplayData?.positions) && (p.combatReplayData.positions.length ?? 0) > 0);
199
+ const numTicks = tagPositions.length;
200
+ let spreadSum = 0;
201
+ let peakSpreadValue = 0;
202
+ let peakSpreadAtSec = 0;
203
+ let tagToCentroidSum = 0;
204
+ let peakLeadValue = 0;
205
+ let peakLeadAtSec = 0;
206
+ const deathCoords = [];
207
+ for (const player of squad) {
208
+ for (const { px, py } of linkedDeathHits(player, tagPositions, pollingRate)) {
209
+ deathCoords.push([px, py]);
210
+ }
211
+ }
212
+ for (let i = 0; i < numTicks; i++) {
213
+ const atSec = i * pollingRate / 1e3;
214
+ const [tx, ty] = tagPositions[i];
215
+ if (squadNonCmdr.length > 0) {
216
+ let spreadAtTick = 0;
217
+ let centroidX = 0;
218
+ let centroidY = 0;
219
+ for (const player of squadNonCmdr) {
220
+ const positions = player.combatReplayData.positions;
221
+ const playerStart = Number(player.combatReplayData?.start ?? 0);
222
+ const playerOffset = Math.floor(playerStart / pollingRate);
223
+ const pIdx = clamp(i - playerOffset, 0, positions.length - 1);
224
+ const [px, py] = positions[pIdx];
225
+ spreadAtTick += Math.hypot(px - tx, py - ty) / inchToPixel;
226
+ centroidX += px;
227
+ centroidY += py;
228
+ }
229
+ const avgSpreadAtTick = spreadAtTick / squadNonCmdr.length;
230
+ spreadSum += avgSpreadAtTick;
231
+ if (avgSpreadAtTick > peakSpreadValue) {
232
+ peakSpreadValue = avgSpreadAtTick;
233
+ peakSpreadAtSec = atSec;
234
+ }
235
+ centroidX /= squadNonCmdr.length;
236
+ centroidY /= squadNonCmdr.length;
237
+ const tagToCentroidDist = Math.hypot(tx - centroidX, ty - centroidY) / inchToPixel;
238
+ tagToCentroidSum += tagToCentroidDist;
239
+ if (tagToCentroidDist > peakLeadValue) {
240
+ peakLeadValue = tagToCentroidDist;
241
+ peakLeadAtSec = atSec;
242
+ }
243
+ }
244
+ }
245
+ const avgSpread = numTicks > 0 ? spreadSum / numTicks : 0;
246
+ const peakAvgRatio = avgSpread > 0 ? peakSpreadValue / avgSpread : 1;
247
+ const cohesionNote = peakAvgRatio > 2.5 ? "tight then scattered" : "held together";
248
+ const squadFollowLag = numTicks > 0 ? tagToCentroidSum / numTicks : 0;
249
+ const CLUSTER_CELL = 150;
250
+ const cellMap = /* @__PURE__ */ new Map();
251
+ for (const [x, y] of deathCoords) {
252
+ const cellX = Math.floor(x / CLUSTER_CELL);
253
+ const cellY = Math.floor(y / CLUSTER_CELL);
254
+ const key = `${cellX},${cellY}`;
255
+ const existing = cellMap.get(key);
256
+ if (existing) {
257
+ existing.sumX += x;
258
+ existing.sumY += y;
259
+ existing.count++;
260
+ } else {
261
+ cellMap.set(key, { sumX: x, sumY: y, count: 1 });
262
+ }
263
+ }
264
+ const deathClusters = Array.from(cellMap.values()).map(({ sumX, sumY, count }) => ({ x: sumX / count, y: sumY / count, count })).sort((a, b) => b.count - a.count).slice(0, 6);
265
+ const stride = Math.ceil(1e3 / pollingRate);
266
+ const tagPath = [];
267
+ for (let i = 0; i < tagPositions.length; i += stride) {
268
+ tagPath.push(tagPositions[i]);
269
+ }
270
+ const downCoords = [];
271
+ for (const player of squad) {
272
+ const replay = player?.combatReplayData;
273
+ if (!replay || !Array.isArray(replay.down) || !Array.isArray(replay.positions)) continue;
274
+ const playerStart = Number(replay.start ?? 0);
275
+ const playerOffset = Math.floor(playerStart / pollingRate);
276
+ for (const entry of replay.down) {
277
+ if (!Array.isArray(entry)) continue;
278
+ const downStartMs = Number(entry[0]);
279
+ if (!Number.isFinite(downStartMs) || downStartMs < 0) continue;
280
+ const pollIndex = Math.floor(downStartMs / pollingRate);
281
+ const playerIdx = clamp(pollIndex - playerOffset, 0, replay.positions.length - 1);
282
+ const [px, py] = replay.positions[playerIdx];
283
+ downCoords.push([px, py]);
284
+ }
285
+ }
286
+ const spreadTimeline = [];
287
+ let sumMassX = 0;
288
+ let sumMassY = 0;
289
+ let massCount = 0;
290
+ let minX = Infinity;
291
+ let maxX = -Infinity;
292
+ let minY = Infinity;
293
+ let maxY = -Infinity;
294
+ for (let i = 0; i < numTicks; i += stride) {
295
+ const atSec = i * pollingRate / 1e3;
296
+ const [tx, ty] = tagPositions[i];
297
+ let spreadAtTick = 0;
298
+ let centroidX = 0;
299
+ let centroidY = 0;
300
+ for (const player of squadNonCmdr) {
301
+ const positions = player.combatReplayData.positions;
302
+ const playerStart = Number(player.combatReplayData?.start ?? 0);
303
+ const playerOffset = Math.floor(playerStart / pollingRate);
304
+ const pIdx = clamp(i - playerOffset, 0, positions.length - 1);
305
+ const [px, py] = positions[pIdx];
306
+ spreadAtTick += Math.hypot(px - tx, py - ty) / inchToPixel;
307
+ centroidX += px;
308
+ centroidY += py;
309
+ }
310
+ if (squadNonCmdr.length > 0) {
311
+ centroidX /= squadNonCmdr.length;
312
+ centroidY /= squadNonCmdr.length;
313
+ spreadTimeline.push([atSec, Math.round(spreadAtTick / squadNonCmdr.length)]);
314
+ sumMassX += centroidX;
315
+ sumMassY += centroidY;
316
+ minX = Math.min(minX, centroidX);
317
+ maxX = Math.max(maxX, centroidX);
318
+ minY = Math.min(minY, centroidY);
319
+ maxY = Math.max(maxY, centroidY);
320
+ massCount++;
321
+ }
322
+ }
323
+ const massX = massCount > 0 ? sumMassX / massCount : 0;
324
+ const massY = massCount > 0 ? sumMassY / massCount : 0;
325
+ const massR = massCount > 0 ? Math.hypot(maxX - minX, maxY - minY) / 2 : 0;
326
+ const sizes = meta.sizes ?? [0, 0];
327
+ const figure = {
328
+ map: { sizes, inchToPixel },
329
+ tagPath,
330
+ squadMass: { x: Math.round(massX), y: Math.round(massY), r: Math.round(massR) },
331
+ deaths: deathCoords,
332
+ downs: downCoords,
333
+ spread: spreadTimeline,
334
+ peakSpread: Math.round(peakSpreadValue)
335
+ };
336
+ return {
337
+ degree,
338
+ perPlayer,
339
+ outOfPositionDeaths,
340
+ squad: {
341
+ avgSpread: Math.round(avgSpread),
342
+ peakSpread: { value: Math.round(peakSpreadValue), atSec: peakSpreadAtSec },
343
+ cohesionNote
344
+ },
345
+ commander: {
346
+ account: commander?.account ?? "Unknown",
347
+ peakLeadFromSquad: { value: Math.round(peakLeadValue), atSec: peakLeadAtSec },
348
+ squadFollowLag: Math.round(squadFollowLag)
349
+ },
350
+ deathClusters,
351
+ figure
352
+ };
353
+ }
89
354
  export {
90
355
  DEFAULT_DISRUPTION_METHOD,
91
356
  METRICS_SPEC,
92
357
  NON_DAMAGING_CONDITIONS,
358
+ OUT_OF_POSITION,
93
359
  PROFESSION_COLORS,
94
360
  ROLLUP_SOURCES_VERSION,
95
361
  ReportSchemaError,
@@ -97,12 +363,14 @@ export {
97
363
  applySquadStabilityGeneration,
98
364
  buildConditionIconMap,
99
365
  buildRollupData,
366
+ classifyDegree,
100
367
  compareRunSets,
101
368
  computeDownContribution,
102
369
  computeIncomingDisruptions,
103
370
  computeOutgoingConditions,
104
371
  computeOutgoingCrowdControl,
105
372
  computePlayerAggregation,
373
+ computePositioning,
106
374
  computeSquadBarrier,
107
375
  computeSquadHealing,
108
376
  createPlayerAggregationAccumulators,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiapps/bridge-metrics",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "AxiBridge report aggregation core: player aggregation, rollup builder, metric extractors",
6
6
  "license": "GPL-3.0-only",
@@ -33,6 +33,7 @@
33
33
  "publishConfig": { "access": "public" },
34
34
  "scripts": {
35
35
  "build": "tsup",
36
+ "prepare": "tsup",
36
37
  "prepublishOnly": "tsup",
37
38
  "test": "vitest run --maxWorkers=2"
38
39
  },