-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscoring.js
More file actions
182 lines (157 loc) · 6.42 KB
/
Copy pathscoring.js
File metadata and controls
182 lines (157 loc) · 6.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import { DEFAULT_PROMOTE, isDown, isUp, JUMP_SIZE, MOVE, TIER_COUNT } from '../config/constants'
/**
* 점수 공식의 계수. 리그 성격을 바꾸는 손잡이라 이름을 붙여 밖으로 뺐습니다.
* 개인 기여를 더 강조하려면 TEAM_RANK_WEIGHT를 낮춥니다.
*/
export const KD_WEIGHT = 10
export const TEAM_RANK_WEIGHT = 2
/** 데스가 0이면 0으로 나누게 되므로 킬 수를 그대로 K/D로 씁니다. */
export function ratio(kills, deaths) {
return deaths > 0 ? kills / deaths : kills
}
/** 종합 점수 = (K/D × 10) + (팀 수 + 1 − 팀 순위) × 2 */
export function playerScore(kills, deaths, teamRank, teamCount) {
return ratio(kills, deaths) * KD_WEIGHT + (teamCount + 1 - teamRank) * TEAM_RANK_WEIGHT
}
/** 점수 내림차순, 동점이면 K/D가 높은 쪽이 앞. 여러 곳에서 같은 기준을 써야 해 하나로 둡니다. */
const byScore = (a, b) => b.score - a.score || b.kd - a.kd
/** 이동 인원은 티어 인원의 절반을 넘을 수 없습니다(상·하위가 겹치면 규칙이 무너집니다). */
function resolvePromote(season, teamCount) {
return Math.max(0, Math.min(season.promote ?? DEFAULT_PROMOTE, Math.floor(teamCount / 2)))
}
/** 팀 배열을 티어별 선수 목록으로 펼칩니다. players의 인덱스가 곧 티어입니다. */
function collectTiers(teams, teamCount) {
const tiers = Array.from({ length: TIER_COUNT }, () => [])
for (const team of teams) {
team.players.slice(0, TIER_COUNT).forEach(([name, kills, deaths], tier) => {
tiers[tier].push({
name,
kills,
deaths,
kd: ratio(kills, deaths),
team: team.name,
teamRank: team.rank,
tier,
score: playerScore(kills, deaths, team.rank, teamCount),
})
})
}
return tiers
}
/**
* 통합 순위를 티어 정원만큼씩 잘라 "점수로만 보면 몇 티어인가"를 매깁니다.
* 실제 티어와 이 값이 JUMP_SIZE 이상 벌어진 선수가 2단 이동 자격을 얻습니다.
*/
function assignMeritTier(players, tierSize) {
players.forEach((p, i) => {
p.rank = i + 1
p.meritTier = Math.min(TIER_COUNT - 1, Math.floor(i / tierSize))
})
}
/**
* 2단 이동을 1:1 스왑으로 짭니다.
*
* 짝을 맞추는 이유는 티어별 인원을 유지하기 위해서입니다. 올라갈 자격자만 보내면
* 위 티어 인원이 늘고 아래 티어가 줍니다. 그래서 hi티어의 자격자(점수 높은 순)와
* lo티어의 자격자(점수 낮은 순)를 같은 수만큼 뽑아 자리를 맞바꿉니다.
*
* 자격자 수가 서로 다르면 적은 쪽에 맞추고, 한 시즌 이동 인원(promote)도 넘지 않습니다.
*/
function planDoubleSwaps(players, promote) {
const swaps = []
for (let hi = JUMP_SIZE; hi < TIER_COUNT; hi++) {
const lo = hi - JUMP_SIZE
// players는 점수 내림차순이므로 risers는 잘한 순, fallers는 뒤집어 못한 순이 됩니다.
const risers = players.filter((p) => p.tier === hi && p.meritTier <= lo)
const fallers = players.filter((p) => p.tier === lo && p.meritTier >= hi).reverse()
const pairs = Math.min(risers.length, fallers.length, promote)
for (let i = 0; i < pairs; i++) swaps.push({ up: risers[i], down: fallers[i] })
}
return swaps
}
/**
* 티어 사이 경계마다 남은 일반 이동 인원을 셉니다. 경계 i는 티어 i와 i+1 사이입니다.
*
* 2단 이동은 건너뛴 경계를 모두 지나가므로, 지나간 경계의 일반 슬롯을 하나씩 씁니다.
* 이 처리가 없으면 2단 이동이 생길 때마다 위 티어 인원이 늘어납니다.
*/
function planSlots(swaps, promote) {
const slots = Array.from({ length: TIER_COUNT - 1 }, () => promote)
for (const { up, down } of swaps) {
for (let b = down.tier; b < up.tier; b++) slots[b] -= 1
}
return slots
}
/**
* 티어 하나를 정렬하고 이동 방향을 붙입니다.
* 2단 이동이 확정된 선수는 이미 갈 곳이 정해졌으므로 일반 슬롯 경쟁에서 빠집니다.
*/
function assignMoves(list, tier, slots, fixed) {
list.sort(byScore)
list.forEach((p, i) => {
p.tierPos = i + 1
})
const upCount = tier > 0 ? slots[tier - 1] : 0
const downCount = tier < TIER_COUNT - 1 ? slots[tier] : 0
const rest = list.filter((p) => !fixed.has(p))
rest.forEach((p, i) => {
const rises = i < upCount
const falls = i >= rest.length - downCount
p.move = rises ? MOVE.UP : falls ? MOVE.DOWN : MOVE.HOLD
p.nextTier = tier + (rises ? -1 : falls ? 1 : 0)
})
}
/** 이동을 적용한 뒤의 티어별 로스터. */
function buildNextTiers(players) {
const nextTiers = Array.from({ length: TIER_COUNT }, () => [])
for (const p of players) nextTiers[p.nextTier].push(p)
for (const list of nextTiers) list.sort(byScore)
return nextTiers
}
function summarize(players) {
const kills = players.reduce((s, p) => s + p.kills, 0)
const deaths = players.reduce((s, p) => s + p.deaths, 0)
return {
kills,
deaths,
leagueKd: ratio(kills, deaths),
promoted: players.filter((p) => isUp(p.move)).length,
relegated: players.filter((p) => isDown(p.move)).length,
jumped: players.filter((p) => p.move === MOVE.UP2 || p.move === MOVE.DOWN2).length,
}
}
/**
* 시즌 하나를 화면이 그대로 쓸 수 있는 형태로 만듭니다.
*
* 통합 순위 → 2단 이동 자격 판정 → 경계별 남은 슬롯 → 티어별 일반 이동 순으로 쌓습니다.
* 통합 순위를 먼저 내는 이유는, 2단 이동 자격이 티어 안이 아니라 리그 전체와의
* 비교에서 나오기 때문입니다.
*/
export function buildStandings(season) {
const teamCount = season.teams.length
const promote = resolvePromote(season, teamCount)
const tiers = collectTiers(season.teams, teamCount)
// 리더보드는 티어와 무관하게 점수 하나로 줄 세웁니다.
const players = tiers.flat().sort(byScore)
assignMeritTier(players, teamCount)
const swaps = planDoubleSwaps(players, promote)
const slots = planSlots(swaps, promote)
const fixed = new Set()
for (const { up, down } of swaps) {
up.move = MOVE.UP2
up.nextTier = up.tier - JUMP_SIZE
down.move = MOVE.DOWN2
down.nextTier = down.tier + JUMP_SIZE
fixed.add(up).add(down)
}
tiers.forEach((list, tier) => assignMoves(list, tier, slots, fixed))
return {
tiers,
nextTiers: buildNextTiers(players),
players,
promote,
teamCount,
swaps,
totals: summarize(players),
}
}