Created
July 2, 2025 11:48
-
-
Save BoYanZh/13dc57298ddcee6acdebf7719bda955b to your computer and use it in GitHub Desktop.
2D Simulation of Apex Legends Ranked Points
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import math | |
| import random | |
| import matplotlib.pyplot as plt | |
| from tqdm import tqdm | |
| SCORE_DICT = { | |
| 20: (0, 10), | |
| 19: (0, 10), | |
| 18: (0, 10), | |
| 17: (0, 10), | |
| 16: (0, 10), | |
| 15: (5, 10), | |
| 14: (5, 10), | |
| 13: (5, 10), | |
| 12: (5, 10), | |
| 11: (5, 10), | |
| 10: (10, 12), | |
| 9: (10, 12), | |
| 8: (20, 14), | |
| 7: (20, 14), | |
| 6: (30, 16), | |
| 5: (45, 16), | |
| 4: (55, 16), | |
| 3: (70, 18), | |
| 2: (95, 22), | |
| 1: (125, 26), | |
| } | |
| PLACEMENT_SCORE = [SCORE_DICT[i][0] for i in range(20, 0, -1)] | |
| KILL_SCORE = [SCORE_DICT[i][1] for i in range(20, 0, -1)] | |
| TEAM_COUNT = 20 | |
| MY_TEAM_ID = TEAM_COUNT - 1 | |
| assert len(PLACEMENT_SCORE) == TEAM_COUNT | |
| assert len(KILL_SCORE) == TEAM_COUNT | |
| class Team: | |
| id: int | |
| pos: tuple[float, float] # pos is now a tuple of two floats | |
| elo: float | |
| win_count: int | |
| placement: int | |
| def __init__(self, id, pos, elo=2000): | |
| self.id = id | |
| self.pos = pos # pos should now be a tuple of two floats | |
| self.elo = elo | |
| self.placement = 0 | |
| self.win_count = 0 | |
| def __repr__(self) -> str: | |
| return f"Team {self.id}, pos {self.pos[0]:.2f}, {self.pos[1]:.2f}, placement {self.placement}" | |
| def generate_init_pos() -> tuple[float, float]: | |
| angle = 2 * math.pi * random.random() # Random angle | |
| radius = math.sqrt(random.random()) # Random radius | |
| x = radius * math.cos(angle) # Convert polar to Cartesian | |
| y = radius * math.sin(angle) | |
| return x, y | |
| def find_nearest(teams: list[Team], run_p) -> tuple[int, int]: | |
| min_distance = math.inf | |
| nearest_index = (0, 0) | |
| for i in range(len(teams)): | |
| for j in range(i + 1, len(teams)): | |
| distance = math.sqrt( | |
| (teams[i].pos[0] - teams[j].pos[0]) ** 2 | |
| + (teams[i].pos[1] - teams[j].pos[1]) ** 2 | |
| ) | |
| if distance < min_distance: | |
| min_distance = distance | |
| nearest_index = (i, j) | |
| if ( | |
| teams[nearest_index[0]].id == MY_TEAM_ID | |
| or teams[nearest_index[1]].id == MY_TEAM_ID | |
| ): | |
| if random.random() < run_p: | |
| return find_nearest([x for x in teams if x.id != MY_TEAM_ID], 0) | |
| return nearest_index | |
| def generate_new_pos(team1, team2): | |
| factor = random.random() # Generate a random factor between 0 and 1 | |
| new_pos = ( | |
| (1 - factor) * team1.pos[0] + factor * team2.pos[0], | |
| (1 - factor) * team1.pos[1] + factor * team2.pos[1], | |
| ) | |
| return new_pos | |
| def simulate( | |
| p=0.5, run_p=0.0, init_pos=None, active_fighter=False | |
| ) -> tuple[int, int, list[Team]]: | |
| teams = [ | |
| Team(i, generate_init_pos()) for i in range(TEAM_COUNT) | |
| ] # pos is now a tuple of two random floats | |
| teams[MY_TEAM_ID].elo += 400 * math.log(p / (1 - p), 10) | |
| if init_pos is not None: | |
| teams[MY_TEAM_ID].pos = init_pos | |
| res: list[Team] = [] | |
| my_team_fighted = False | |
| while len(teams) != 1: | |
| if len(teams) == 2: | |
| run_p = 0 | |
| ti1, ti2 = find_nearest(teams, run_p) | |
| if teams[ti1].id == MY_TEAM_ID or teams[ti2].id == MY_TEAM_ID: | |
| my_team_fighted = True | |
| if active_fighter and not my_team_fighted: | |
| if random.random() < 1 - len(teams) / TEAM_COUNT: | |
| ti1 = next(i for i, x in enumerate(teams) if x.id == MY_TEAM_ID) | |
| my_team_fighted = True | |
| elo_diff = teams[ti1].elo - teams[ti2].elo | |
| win_rate = 1 / (1 + 10 ** (-elo_diff / 400)) | |
| win = random.random() < win_rate | |
| new_pos = generate_new_pos(teams[ti1], teams[ti2]) | |
| winner_idx = ti1 if win else ti2 | |
| loser_idx = ti2 if win else ti1 | |
| teams[winner_idx].pos = new_pos | |
| teams[winner_idx].win_count += 1 | |
| teams[loser_idx].placement = len(teams) | |
| res.append(teams[loser_idx]) | |
| teams.pop(loser_idx) | |
| for team in teams: | |
| team.pos = ( | |
| team.pos[0] + random.uniform(-0.1, 0.1), | |
| team.pos[1] + random.uniform(-0.1, 0.1), | |
| ) | |
| teams[0].placement = 1 | |
| res.append(teams[0]) | |
| t: Team = next((x for x in res if x.id == MY_TEAM_ID)) | |
| return t.placement, t.win_count, res | |
| def simulate_distribution(p=0.5, run_p=0.0, init_pos=None, active_fighter=False): | |
| print( | |
| f"Simulating with p={p}, run_p={run_p}, " | |
| f"init_pos={'random' if init_pos is None else init_pos}, " | |
| f"active_fighter={active_fighter}" | |
| ) | |
| placements = [0.0] * TEAM_COUNT | |
| win_counts = [0.0] * TEAM_COUNT | |
| for _ in tqdm(range(SIMULATE_COUNT)): | |
| placement, win_count, _ = simulate(p, run_p, init_pos, active_fighter) | |
| placements[placement - 1] += 1 | |
| win_counts[placement - 1] += win_count | |
| win_counts = [ | |
| win_count / placements[i] if placements[i] != 0 else 0 | |
| for i, win_count in enumerate(win_counts) | |
| ] | |
| placements = [i / SIMULATE_COUNT for i in placements] | |
| return placements, win_counts | |
| def markov_chain_distribution(p=0.5, n=TEAM_COUNT): | |
| import numpy as np | |
| P = np.zeros((n + 1, n + 1)) # placement | |
| B = np.zeros(n + 1) # battle | |
| for i in range(2, n + 1): | |
| P[i, i] = 2 / i * (1 - p) # lose | |
| P[i, i - 1] = 1 - P[i, i] # win | |
| P[1, 1] = 1 # it can only stay at top-1 | |
| for i in range(n - 1, 0, -1): | |
| P[i, i - 1] *= P[i + 1, i] | |
| P[i, i] *= P[i + 1, i] | |
| B[i] = B[i + 1] + 2 / i * p | |
| return np.diag(P)[1:].tolist(), B[1:].tolist() | |
| def draw(p, run_p, placement_distribution, win_count_distribution, active_fighter): | |
| avg_placement_score, avg_kill_score = 0, 0 | |
| avg_score_min = [0] * TEAM_COUNT | |
| for i in range(TEAM_COUNT): | |
| avg_placement_score += ( | |
| PLACEMENT_SCORE[TEAM_COUNT - i - 1] * placement_distribution[i] | |
| ) | |
| avg_kill_score += ( | |
| KILL_SCORE[TEAM_COUNT - i - 1] | |
| * win_count_distribution[i] | |
| * placement_distribution[i] | |
| * 2.5 # 1 kill, 1 assist, 1 missed | |
| ) | |
| avg_score_min[i] = ( | |
| PLACEMENT_SCORE[TEAM_COUNT - i - 1] | |
| + KILL_SCORE[TEAM_COUNT - i - 1] | |
| * win_count_distribution[i] | |
| * placement_distribution[i] | |
| * 2.5 | |
| ) / (TEAM_COUNT - i) | |
| avg_time = sum([(TEAM_COUNT - i) * placement_distribution[i] for i in range(20)]) | |
| x = range(1, TEAM_COUNT + 1) | |
| fig, ax1 = plt.subplots() | |
| ax1.bar(x, placement_distribution) | |
| ax1.set_xlabel("# rank") | |
| ax1.set_ylabel("probability") | |
| ax1.set_xticks(x) | |
| ax2 = ax1.twinx() | |
| ax2.plot(x, avg_score_min, "r") | |
| ax2.set_ylabel("avg score/min") | |
| title = ( | |
| f"win rate: {p:.2f}, " | |
| f"run rate: {run_p:.2f}, " | |
| f"active fighter: {active_fighter}\n" | |
| f"avg placement score: {(avg_placement_score):.2f}, " | |
| f"avg kill score: {(avg_kill_score):.2f}\n" | |
| f"avg total score: {(avg_placement_score + avg_kill_score):.2f}, avg time: {avg_time:.2f}, " | |
| f"avg score/min: {((avg_placement_score + avg_kill_score) / avg_time):.2f}" | |
| ) | |
| plt.title(title) | |
| print(title) | |
| fig.tight_layout() | |
| plt.savefig(f"img/{p:.2f}_{run_p:.2f}_{active_fighter}.png") | |
| plt.clf() | |
| plt.close() | |
| def kd_to_p(kd): | |
| return kd / (kd + 1) | |
| def p_to_kd(p): | |
| return round(p / (1 - p), 2) | |
| def main(): | |
| init_pos = None | |
| run_p = 0.0 | |
| # for active_fighter in [True, False]: | |
| for active_fighter in [True]: | |
| for kd in [1, 0.5, 0.75, 1.25, 1.5, 1.75, 2]: | |
| p = kd_to_p(kd) | |
| placement_distribution, win_count_distribution = simulate_distribution( | |
| p, | |
| run_p, | |
| init_pos, | |
| active_fighter, | |
| ) | |
| # placement_distribution, win_count_distribution = markov_chain_distribution(p) | |
| draw( | |
| p, | |
| run_p, | |
| placement_distribution, | |
| win_count_distribution, | |
| active_fighter, | |
| ) | |
| if __name__ == "__main__": | |
| SIMULATE_COUNT = 100000 | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment