@@ -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