Skip to content

Commit 316e792

Browse files
authored
Merge pull request #2974 from geracosta/position-est
Add position_est, an estimated 1-5 position for parsed matches
2 parents 2d67379 + 729b39f commit 316e792

4 files changed

Lines changed: 93 additions & 1 deletion

File tree

global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ interface ParsedPlayer extends Player {
153153

154154
// Computed
155155
is_roaming?: boolean | null;
156+
position_est?: number;
156157
all_word_counts: NumberDict;
157158
my_word_counts: NumberDict;
158159
throw: number | undefined;

svc/api/responses/MatchResponse.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,12 @@ export default {
824824
type: "boolean",
825825
nullable: true,
826826
},
827+
position_est: {
828+
description:
829+
"Estimated position (1-5) of the player within their team, from early farm priority and lane data. Only present on parsed matches",
830+
type: "integer",
831+
nullable: true,
832+
},
827833
purchase_time: {
828834
description:
829835
"Object with information on when the player last purchased an item",

svc/util/buildMatch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { heroes } from "dotaconstants";
22
import config from "../../config.ts";
3-
import { computeMatchData } from "./compute.ts";
3+
import { computeMatchData, estimatePositions } from "./compute.ts";
44
import { buildReplayUrl, isTurbo } from "./utility.ts";
55
import redis, { redisCount } from "../store/redis.ts";
66
import db from "../store/db.ts";
@@ -266,6 +266,7 @@ export async function buildMatch(
266266
: undefined,
267267
};
268268
computeMatchData(matchResult as ParsedPlayerMatch);
269+
estimatePositions(matchResult.players as ParsedPlayerMatch[]);
269270

270271
// Save in cache
271272
if (matchResult && config.ENABLE_MATCH_CACHE) {

svc/util/compute.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,90 @@ export function computeMatchData(pm: ParsedPlayerMatch) {
251251
}
252252
}
253253

254+
/**
255+
* Estimates each player's position (1-5) on parsed matches.
256+
* Within each team, players are ranked by early farm priority (gold_t/lh_t
257+
* averaged over minutes 10-12, early ward purchases breaking ties toward
258+
* support): the top 3 are cores and the bottom 2 supports. Cores map to 1/2/3
259+
* through their lane_role; supports on the safe lane read as 5 and elsewhere
260+
* as 4 (in the current meta the 4 plays around mid while the 5 roams, so
261+
* is_roaming deliberately plays no part). Unresolved conflicts fall back to
262+
* farm order. Sets position_est only when the whole team has the parsed data.
263+
* Accuracy notes and validation in the issue: 98.9% exact vs reference labels
264+
* on 100 pro matches (https://github.com/odota/core/issues/1590).
265+
* */
266+
export function estimatePositions(players: ParsedPlayerMatch[]) {
267+
const EARLY_SECONDS = 12 * 60;
268+
for (const side of [true, false]) {
269+
const team = players.filter((p) => isRadiant(p) === side);
270+
const parsed = team.every(
271+
(p) =>
272+
p.gold_t &&
273+
p.gold_t.length > 12 &&
274+
p.lh_t &&
275+
p.lh_t.length > 12 &&
276+
p.lane_role != null,
277+
);
278+
if (team.length !== 5 || !parsed) {
279+
continue;
280+
}
281+
const avgWindow = (arr: number[]) => (arr[10] + arr[11] + arr[12]) / 3;
282+
const scored = team.map((p) => ({
283+
p,
284+
gold: avgWindow(p.gold_t),
285+
lh: avgWindow(p.lh_t),
286+
wards: (p.purchase_log ?? []).filter(
287+
(e) =>
288+
(e.key === "ward_observer" || e.key === "ward_sentry") &&
289+
e.time <= EARLY_SECONDS,
290+
).length,
291+
rank_gold: 0,
292+
rank_lh: 0,
293+
farmRank: 0,
294+
}));
295+
for (const key of ["gold", "lh"] as const) {
296+
const sorted = [...scored].sort((a, b) => b[key] - a[key]);
297+
scored.forEach((s) => {
298+
s[key === "gold" ? "rank_gold" : "rank_lh"] = sorted.indexOf(s);
299+
});
300+
}
301+
// lower farmRank = higher farm priority; wards break ties toward support
302+
scored.forEach((s) => {
303+
s.farmRank = s.rank_gold + s.rank_lh;
304+
});
305+
scored.sort((a, b) => a.farmRank - b.farmRank || a.wards - b.wards);
306+
const assign = (
307+
group: typeof scored,
308+
wanted: number[],
309+
prefer: (p: ParsedPlayerMatch) => number | null,
310+
) => {
311+
const taken = new Set<number>();
312+
const unassigned: typeof scored = [];
313+
for (const s of group) {
314+
const want = prefer(s.p);
315+
if (want != null && wanted.includes(want) && !taken.has(want)) {
316+
s.p.position_est = want;
317+
taken.add(want);
318+
} else {
319+
unassigned.push(s);
320+
}
321+
}
322+
const remaining = wanted.filter((w) => !taken.has(w));
323+
unassigned.forEach((s, i) => {
324+
s.p.position_est = remaining[i];
325+
});
326+
};
327+
assign(scored.slice(0, 3), [1, 2, 3], (p) =>
328+
p.lane_role != null && p.lane_role >= 1 && p.lane_role <= 3
329+
? p.lane_role
330+
: null,
331+
);
332+
assign(scored.slice(3), [4, 5], (p) =>
333+
p.lane_role === 2 || p.lane_role === 3 ? 4 : p.lane_role === 1 ? 5 : null,
334+
);
335+
}
336+
}
337+
254338
/**
255339
* Determines if a match is significant for aggregation purposes
256340
* */

0 commit comments

Comments
 (0)