#!/usr/bin/env python3
"""4v4 IDOL RACING v5.2 · Python 3.10+ · 별도 패키지 불필요
능력치: 20~80 스케일.
SPD: 모든 구간의 기본 주행 속도와 평균 가속력
COR: 코너 통과 속도, 제동, 주행선 변경, 차체 안정과 회복
ACC: SPD/COR로 정해진 성능을 출발·추월·코너 탈출·부스트에서 증폭
SEN: 추월 타이밍과 돌발 상황을 판단하고 관련 능력치를 잠시 상승
내부 제어 계산은 (능력치-20)×100/60으로 환산하며 출력은 원래 숫자를 유지합니다.
기본 목표속도는 다음처럼 역할을 분리합니다(d=코너 난도, km/h).
직선 = (225 + 0.75×(SPD-50)) × (1 + 0.0014×(ACC-50))
코너 = (162.5 + 0.40×(SPD-50) + 0.70×(COR-50))
× (1 + 0.0006×(ACC-50)) × (1 - 0.42×d)
5점 차이는 랩타임에 남기되 여러 능력치 차이가 겹쳐 기록이 과도하게 벌어지지
않도록 최고속도와 가속력 계수를 조정했습니다.
myCompiler: 이 파일 전체를 Python 코드 창에 붙여 넣고, stdin에 팀 이름을
한 줄에 하나씩 입력합니다. 인자 없이 실행하면 stdin 모드로 시작합니다.
최소 입력:
aespa
IVE
선택 설정을 덧붙일 수 있습니다:
맵=all
A=카리나,지젤,윈터,닝닝
B=안유진,레이,장원영,리즈
A성향=공격형
B성향=팀플레이형
시드=42
반복=1
랩=3
로그=켜기
맵: speedway(1), harbor(2, 기본), mountain(3), city(4), all(전체).
성향: 공격형 / 균형형(기본) / 안정형 / 팀플레이형.
개인별 성향 지정: A성향=카리나:공격형,윈터:안정형 (미지정 선수는 균형형).
로그: 기본은 공식 결과표만 출력. 요약은 선수 통계와 주요 사건 12개,
켜기는 랩타임·선수 통계·저장된 사건 최대 300개를 출력합니다.
A/B 생략 시 맵별 기본 예상 기록 상위 4명 자동 선발. 상호작용까지 예측하는
최적 조합 탐색은 아닙니다. 직접 지정하면 모든 맵에서 같은 4명이 출전합니다.
등록 예시 명단은 aespa 4명과 IVE 6명이며 ROSTERS에서 추가·수정할 수 있습니다.
엔진과 규칙:
• 0.1초마다 누적 거리·속도·횡방향 위치·부스트 잔량을 갱신합니다.
0.5초마다 추월, 수비, 양보, 부스트 사용을 판단합니다.
• 직선과 코너 폭이 달라집니다. 같은 주행선에서 앞차를 통과할 수 없습니다.
• 직선에서 앞차 뒤 4.5~55m, 비슷한 주행선이면 슬립스트림으로 최고속도
5.5~8.0%와 가속력이 증가하고 부스트가 추가 충전됩니다. 팀원 뒤에서도 적용합니다.
• 부스트는 2초 지속, 게이지 소모와 재사용 대기 시간이 있습니다.
• COR/SEN/성향에 따라 코너 전 제동 시점이 달라집니다. 늦게 진입하면
추월 기회가 있지만 바깥으로 밀리거나 출구 속도가 떨어질 수 있습니다.
• 접촉은 두 선수의 속도·횡방향 위치·회복 상태를 바꿉니다.
ACC는 밀림 저항, COR는 차체 제어, SEN은 접촉 위험 및 손실에 관여합니다.
팀원끼리도 접촉할 수 있습니다. 고정 시간 벌점은 없습니다.
• ACC는 기본 속도를 독립적으로 만들지 않고 가속·최고속·코너 탈출·부스트와
몸싸움에서 SPD/COR의 결과를 증폭합니다. 낮은 SPD를 완전히 대신할 수 없습니다.
• SEN은 추월·실제 코너 진입 위험·충돌 위기에서 FOCUS를 발동합니다. 발동 중
COR/ACC가 4~10점 상승하며 일시적으로만 90까지 허용됩니다. SEN이 높을수록
발동률과 상승량이 커집니다.
• 출발은 정지 상태 2열×4행. 매 행에 양 팀이 한 명씩 서며 선수/좌우는 시드로
추첨합니다. 첫 랩은 출발 위치 차이와 정지 출발 시간을 포함합니다.
• 각 랩 결승선 통과 시각을 보간해 밀리초로 기록합니다. 마지막 랩까지의
합계 시간으로 개인 순위, 10/7/5/3/2/1/0/0점으로 팀 점수를 정합니다.
• 개인 동률은 차지한 순위의 점수 평균, 팀 동률은 팀 합계 기록,
이것도 같으면 무승부. 시리즈는 누적 포인트 우선, 누적 팀 기록 차순입니다.
• 추월 횟수는 출발 3초 이후 두 선수의 앞뒤가 3m 이상 확실하게 바뀐 경우입니다.
결승선 직전 3m 미만 추월은 기록 순위에는 반영되나 추월 횟수에는 빠질 수 있습니다.
• 실제 차량 물리 엔진은 아닌 게임용 거리/횡방향 모형입니다. 그래픽·DNF 없음.
실수·접촉·슬립스트림·수비는 실제 거리 변화에 영향을 주며 기록을 사후 조정하지 않습니다.
• 같은 입력과 시드로 재현 가능. 시드 생략 시 무작위 값을 출력합니다.
명령행 모드(옵션이 있으면 사용, 가상 예시 팀 또는 JSON 설정):
python racing_simulator.py --maps all --seed 42 --events
python racing_simulator.py --write-example my_league.json
python racing_simulator.py --config my_league.json --maps all --csv results.csv
python racing_simulator.py --points 10,8,6,5,4,3,2,1 --no-incidents
JSON 팀 또는 선수 객체의 style에 주행 성향을 지정할 수 있습니다.
--no-incidents는 실수·접촉 충격만 끕니다. 추월·부스트·주행 방해는 유지합니다.
온라인 실행 시간 제한 때문에 많은 경기를 한 번에 반복하는 것은 피하세요.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import random
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_POINTS = [10, 7, 5, 3, 2, 1, 0, 0]
STATS = ("SPD", "COR", "ACC", "SEN")
RATING_MIN = 20
RATING_MAX = 80
# 등록 능력치는 80이 상한이지만 SEN의 순간 대응은 90까지 허용합니다.
FOCUS_RATING_MAX = 90
FOCUS_INTERNAL_MAX = (FOCUS_RATING_MAX - RATING_MIN) * 100.0 / (RATING_MAX - RATING_MIN)
# 각 행: (선수명, SPD, COR, ACC, SEN). 팀 추가 시 여기에 명단을 넣으세요.
ROSTERS = {
"aespa": [
("카리나", 75, 65, 75, 55), ("지젤", 50, 50, 65, 45),
("윈터", 75, 65, 60, 70), ("닝닝", 65, 60, 60, 75),
],
"IVE": [
("안유진", 70, 65, 75, 65), ("가을", 55, 65, 55, 45),
("레이", 60, 50, 70, 50), ("장원영", 75, 55, 80, 45),
("리즈", 65, 60, 60, 65), ("이서", 60, 55, 65, 50),
],
}
TEAM_ALIASES = {
"에스파": "aespa", "아이브": "IVE",
}
def example_config():
return {
"points": DEFAULT_POINTS.copy(),
"teams": [
{"name": "aespa", "players": [
{"name": name, **dict(zip(STATS, values))}
for name, *values in ROSTERS["aespa"]]},
{"name": "IVE", "players": [
{"name": name, **dict(zip(STATS, values))}
for name, *values in (ROSTERS["IVE"][0], ROSTERS["IVE"][2],
ROSTERS["IVE"][3], ROSTERS["IVE"][4])]},
],
"tracks": [
{"id": "speedway", "name": "스카이 스피드웨이", "laps": 3,
"description": "긴 직선 중심 · SPD/ACC 강점",
"segments": [
["메인 직선", "straight", 1200, 0], ["고속 우회전", "corner", 300, 0.25],
["백스트레이트", "straight", 1000, 0], ["뱅크 코너", "corner", 400, 0.35],
["피니시 직선", "straight", 600, 0]]},
{"id": "harbor", "name": "네온 하버", "laps": 3,
"description": "직선과 코너 균형 · 종합 주행 능력",
"segments": [
["항구 직선", "straight", 750, 0], ["창고 코너", "corner", 300, 0.6],
["부두 직선", "straight", 450, 0], ["연속 S코너", "corner", 500, 0.7],
["대교 직선", "straight", 700, 0], ["마지막 헤어핀", "corner", 250, 0.9]]},
{"id": "mountain", "name": "미스트 마운틴", "laps": 3,
"description": "헤어핀과 연속 코너 · COR 및 사고 대응 SEN",
"segments": [
["진입 직선", "straight", 400, 0], ["첫 헤어핀", "corner", 300, 0.95],
["연속 커브", "corner", 650, 0.8], ["능선 직선", "straight", 300, 0],
["더블 헤어핀", "corner", 500, 1.0], ["출구 커브", "corner", 400, 0.7]]},
{"id": "city", "name": "다운타운 스트리트", "laps": 4,
"description": "짧은 직선과 잦은 코너 · 접전 및 실수 변수",
"segments": [
["출발 직선", "straight", 300, 0], ["광장 코너", "corner", 200, 0.65],
["골목 직선", "straight", 180, 0], ["직각 코너", "corner", 180, 0.9],
["상가 직선", "straight", 280, 0], ["시케인", "corner", 350, 0.95],
["역앞 직선", "straight", 350, 0], ["공원 커브", "corner", 250, 0.6]]},
],
}
def number(value, low, high, label):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{label}: 숫자를 입력하세요.")
if not math.isfinite(value) or not low <= value <= high:
raise ValueError(f"{label}: {low}~{high} 범위여야 합니다.")
def name(value, label):
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise ValueError(f"{label}: 비어 있지 않은 한 줄 문자열이어야 합니다.")
def validate(config):
teams = config["teams"]
if len(teams) != 2:
raise ValueError("정확히 두 팀이 필요합니다.")
team_names = set()
for team in teams:
name(team["name"], "팀명")
if team.get("style", "균형형") not in STYLE_RULES:
raise ValueError("팀 성향은 공격형/균형형/안정형/팀플레이형 중 하나여야 합니다.")
if team["name"] in team_names:
raise ValueError("팀명은 서로 달라야 합니다.")
team_names.add(team["name"])
if len(team["players"]) != 4:
raise ValueError(f"{team['name']}: 선수는 정확히 4명이어야 합니다.")
player_names = set()
for player in team["players"]:
name(player["name"], "선수명")
if player.get("style", "균형형") not in STYLE_RULES:
raise ValueError(f"{player['name']}: 알 수 없는 주행 성향입니다.")
if player["name"] in player_names:
raise ValueError("한 팀 내 선수명은 서로 달라야 합니다.")
player_names.add(player["name"])
for stat in STATS:
number(player[stat], RATING_MIN, RATING_MAX, f"{player['name']} {stat}")
points = config.get("points", DEFAULT_POINTS)
if len(points) != 8:
raise ValueError("배점은 1~8위까지 8개가 필요합니다.")
for p in points:
number(p, 0, 10000, "배점")
if list(points) != sorted(points, reverse=True):
raise ValueError("배점은 순위가 낮아질수록 같거나 작아야 합니다.")
ids = set()
if not config["tracks"]:
raise ValueError("맵이 하나 이상 필요합니다.")
for track in config["tracks"]:
name(track["id"], "맵 ID")
name(track["name"], "맵명")
if track["id"] in ids or track["id"] == "all":
raise ValueError("맵 ID는 중복되거나 all일 수 없습니다.")
ids.add(track["id"])
if type(track["laps"]) is not int or not 1 <= track["laps"] <= 100:
raise ValueError("랩 수는 1~100 정수여야 합니다.")
if not track["segments"]:
raise ValueError("맵에 구간이 하나 이상 필요합니다.")
for segment in track["segments"]:
if len(segment) != 4:
raise ValueError("구간 형식: [이름, straight/corner, 길이m, 난도]")
label, kind, meters, difficulty = segment
name(label, "구간명")
if kind not in ("straight", "corner"):
raise ValueError("구간 종류는 straight 또는 corner여야 합니다.")
number(meters, 1, 100000, "구간 길이")
number(difficulty, 0, 1, "구간 난도")
@dataclass
class Driver:
team: str
name: str
stats: dict
total_ms: int = 0
laps_ms: list[int] = field(default_factory=list)
errors: int = 0
contacts: int = 0
rank: int = 0
points: float = 0
style: str = "균형형"
overtakes: int = 0
boosts: int = 0
yields: int = 0
defenses: int = 0
focuses: int = 0
draft_seconds: float = 0.0
blocked_seconds: float = 0.0
ability: dict = field(default_factory=dict, init=False, repr=False)
def __post_init__(self):
self.ability = scale_stats(self.stats) if self.stats else {}
def scale_stats(stats):
"""20~80의 차이를 0~100 제어 모형으로 환산합니다. 표시용 원본을 수정하지 않습니다."""
result = {}
for stat in STATS:
value = stats[stat]
number(value, RATING_MIN, RATING_MAX, stat)
result[stat] = (value - RATING_MIN) * 100.0 / (RATING_MAX - RATING_MIN)
return result
def base_speed(stats, kind, difficulty, cor_bonus=0.0, acc_bonus=0.0):
"""기본 목표속도(km/h). bonus는 SEN FOCUS가 잠시 올리는 원래 스케일 점수입니다."""
spd = stats["SPD"]
cor = min(FOCUS_RATING_MAX, stats["COR"] + cor_bonus)
acc = min(FOCUS_RATING_MAX, stats["ACC"] + acc_bonus)
if kind == "straight":
# SPD가 기본 체급, ACC가 그 결과에 최고속 보정을 곱합니다.
return (225 + 0.75 * (spd - 50)) * (1 + 0.0014 * (acc - 50))
# COR가 코너 차이를 만들고, ACC의 상시 코너 보정은 작게 제한합니다.
return ((162.5 + 0.40 * (spd - 50) + 0.70 * (cor - 50))
* (1 + 0.0006 * (acc - 50)) * (1 - 0.42 * difficulty))
def assign_points(drivers, points):
ranked = sorted(drivers, key=lambda d: d.total_ms)
i = 0
while i < len(ranked):
j = i + 1
while j < len(ranked) and ranked[j].total_ms == ranked[i].total_ms:
j += 1
award = sum(points[i:j]) / (j - i)
for d in ranked[i:j]:
d.rank, d.points = i + 1, award
i = j
return ranked
@dataclass
class RaceCar:
driver: Driver
position: float
lane: float
target_lane: float
form: float
speed: float = 0.0
energy: float = 45.0
boost_left: float = 0.0
recovery: float = 0.0
cooldown: float = 0.0
attack_left: float = 0.0
focus_left: float = 0.0
focus_bonus: float = 0.0
focus_cooldown: float = 0.0
decision_noise: float = 0.0
defending: bool = False
yielding: bool = False
last_segment: tuple = (-999, -999)
last_yield: float = -100.0
last_defense: float = -100.0
STYLE_RULES = {
"공격형": {"risk": 1.5, "brake": 0.82, "attack": 1.0, "team": 0.25},
"균형형": {"risk": 1.0, "brake": 1.0, "attack": 0.7, "team": 0.65},
"안정형": {"risk": 0.6, "brake": 1.16, "attack": 0.4, "team": 0.65},
"팀플레이형": {"risk": 0.85, "brake": 1.05, "attack": 0.6, "team": 1.0},
}
def simulate_race(config, track, seed, laps=None, incidents=True):
"""0.1초 고정 간격 위치 시뮬레이션. 기록은 결승선 통과 시각으로 측정합니다.
incidents=False는 실수·접촉 충격만 끕니다. 추월, 주행 방해, 부스트는 유지합니다.
1차원 주행 거리와 연속적인 횡방향 위치를 사용한 게임 모형입니다.
"""
from bisect import bisect_right
rng = random.Random(seed)
dt = 0.1
lap_count = track["laps"] if laps is None else laps
if type(lap_count) is not int or not 1 <= lap_count <= 100:
raise ValueError("랩 수는 1~100 정수여야 합니다.")
segments = track["segments"]
ends = []
length = 0.0
for segment in segments:
length += segment[2]
ends.append(length)
if length < 100:
raise ValueError("8명이 출발할 수 있도록 맵 한 바퀴 길이를 100m 이상으로 설정하세요.")
finish = length * lap_count
drivers = []
for team in config["teams"]:
for player in team["players"]:
style = player.get("style", team.get("style", "균형형"))
if style not in STYLE_RULES:
raise ValueError(f"알 수 없는 주행 성향: {style}")
drivers.append(Driver(team["name"], player["name"], {s: player[s] for s in STATS}, style=style))
# 2열 × 4행 정지 출발. 각 행에 양 팀이 한 명씩 배치되어 팀별 출발 거리 합이 같습니다.
groups = [[i for i, d in enumerate(drivers) if d.team == team["name"]] for team in config["teams"]]
for group in groups:
rng.shuffle(group)
grid = {}
for row, pair in enumerate(zip(*groups)):
pair = list(pair)
rng.shuffle(pair)
for side, i in enumerate(pair):
grid[i] = (-row * 7.0, side * 2.0)
cars = [RaceCar(d, grid[i][0], grid[i][1], grid[i][1], rng.uniform(-0.012, 0.012))
for i, d in enumerate(drivers)]
events = []
event_counts = {}
pair_cooldown = {}
pass_cooldown = {}
# 확실히 3m 이상 앞선 상태에서 반대로 3m 이상 앞서야 추월 1회로 기록합니다.
pair_leader = {(i, j): (i if cars[i].position > cars[j].position else j)
for i in range(8) for j in range(i + 1, 8)
if abs(cars[i].position - cars[j].position) >= 3}
def location(position):
local = max(0.0, position) % length
index = min(bisect_right(ends, local), len(segments) - 1)
return index, ends[index] - local
def bounds(kind, difficulty):
margin = 0.55 * difficulty if kind == "corner" else 0.0
return margin, 2.0 - margin
def event(kind, i, text, event_ms=None):
event_counts[kind] = event_counts.get(kind, 0) + 1
# 장시간 시리즈에서도 로그 메모리를 제한합니다. 집계는 계속 유지합니다.
if len(events) < 300:
index, _ = location(cars[i].position)
events.append({"time_ms": round(now * 1000) if event_ms is None else event_ms,
"lap": min(lap_count, max(0, int(cars[i].position // length)) + 1),
"segment": segments[index][0], "type": kind, "text": text})
def start_focus(i, reason, duration=1.5):
"""SEN이 상황을 읽었을 때 관련 COR/ACC를 원래 스케일 4~10점만큼 잠시 올립니다."""
car, d = cars[i], drivers[i]
if car.focus_cooldown > 0:
return False
sen = d.ability["SEN"]
raw_bonus = 4.0 + 6.0 * sen / 100
car.focus_bonus = raw_bonus * 100 / (RATING_MAX - RATING_MIN)
car.focus_left = max(car.focus_left, duration)
car.focus_cooldown = 6.0
d.focuses += 1
event("집중", i, f"{d.name} FOCUS · {reason} · COR/ACC +{raw_bonus:.1f}")
return True
def relative(a, b):
# 랩이 달라도 같은 도로 위치에 있으면 상호작용합니다.
return (b - a + length / 2) % length - length / 2
steps = 0
# 정상 주행보다 매우 여유로운 상한입니다. 미완주를 임의 기록으로 채우지 않습니다.
max_steps = int((finish / 2 + 120) / dt)
while any(d.total_ms == 0 for d in drivers):
if steps > max_steps:
raise RuntimeError("경기 시간 상한을 초과했습니다. 맵과 설정을 확인하세요.")
now = steps * dt
active = [i for i, d in enumerate(drivers) if not d.total_ms]
positions = [c.position for c in cars]
speeds = [c.speed for c in cars]
lanes = [c.lane for c in cars]
locations = [location(p) for p in positions]
neighbours = {i: [(j, relative(positions[i], positions[j])) for j in active if j != i] for i in active}
# 모든 판단은 같은 시각의 위치·속도 사본을 사용합니다.
if steps % 5 == 0:
decisions = {}
for i in active:
car, d = cars[i], drivers[i]
s, rule = d.ability, STYLE_RULES[d.style]
index, remaining = locations[i]
_, kind, _, difficulty = segments[index]
low, high = bounds(kind, difficulty)
candidates = [low, 1.0, high]
ahead = sorted([(gap, j) for j, gap in neighbours[i]
if 0 < gap < 55 and abs(lanes[i] - lanes[j]) < 0.65])
behind = sorted([(-gap, j) for j, gap in neighbours[i] if -24 < gap < 0])
front = ahead[0] if ahead else None
target_lane = max(low, min(high, lanes[i]))
yielding = defending = False
attacking = False
# SEN은 상황 인지의 성공률에 반영됩니다. 공격성은 별도 성향입니다.
aware = rng.random() < 0.50 + s["SEN"] / 200
natural = base_speed(d.stats, kind, difficulty) / 3.6
ally = next(((gap, j) for gap, j in behind
if now > 3 and drivers[j].team == d.team and abs(lanes[i] - lanes[j]) < 0.65 and
(base_speed(drivers[j].stats, kind, difficulty) / 3.6 > natural + 0.35
or cars[j].boost_left > 0)), None)
def clear(candidate):
lo, hi = sorted((lanes[i], candidate))
return not any(abs(gap) < (4.8 if d.style == "공격형" else 7.0)
and lo - 0.6 < lanes[j] < hi + 0.6 for j, gap in neighbours[i])
available = [candidate for candidate in candidates if clear(candidate)]
if ally and aware and rng.random() < rule["team"] and available:
target_lane = max(available, key=lambda lane: abs(lane - lanes[ally[1]]))
yielding = abs(target_lane - lanes[ally[1]]) > 0.65 and abs(target_lane - lanes[i]) > 0.2
if yielding and now - car.last_yield > 8:
d.yields += 1
car.last_yield = now
event("양보", i, f"{d.name} → {drivers[ally[1]].name} 추월 공간 양보")
elif now > 3 and front and front[0] < (18 if d.style == "공격형" else 12) and available:
# 좁은 도로·옆차 때문에 공간이 없으면 추월을 기다립니다.
def lane_score(lane):
room = min([gap for j, gap in neighbours[i]
if 0 < gap < 60 and abs(lane - lanes[j]) < 0.65] or [60])
inside = low if index % 2 == 0 else high
corner_bonus = (4 * s["COR"] / 100 * (1 - abs(lane - inside) / 2)
if kind == "corner" else 0)
return room + corner_bonus - 3 * abs(lane - lanes[i])
# SEN이 높을수록 실제 추월 기회를 빨리 알아차립니다.
# 성향은 시도 빈도를, SEN은 같은 성향 안의 판단력을 정합니다.
attack_chance = rule["attack"] * (0.55 + 0.45 * s["SEN"] / 100)
if rng.random() < attack_chance:
target_lane = max(available, key=lane_score)
attacking = abs(target_lane - lanes[front[1]]) > 0.65
elif behind and aware and available:
rival = next(((gap, j) for gap, j in behind
if gap < 16 and drivers[j].team != d.team), None)
if rival and rng.random() < (0.75 if d.style == "팀플레이형" else 0.35):
target_lane = min(available, key=lambda lane: abs(lane - lanes[rival[1]]))
defending = abs(target_lane - lanes[i]) > 0.15
if defending and now > 3 and now - car.last_defense > 8:
d.defenses += 1
car.last_defense = now
event("수비", i, f"{d.name} → {drivers[rival[1]].name} 추월 경로 수비 시도")
activate = False
if car.boost_left <= 0 and car.cooldown <= 0 and car.energy >= 48 and kind == "straight" and remaining > 100 and now > 3:
pursuit = front is not None and front[0] < 40
threatened = any(gap < 20 and drivers[j].team != d.team for gap, j in behind)
final_stretch = finish - positions[i] < 500
# SEN이 높으면 추월할 공간을 확인한 뒤 부스트를 사용합니다.
usable = not front or front[0] > 9 or abs(target_lane - lanes[front[1]]) > 0.65
activate = (pursuit or threatened or final_stretch or car.energy > 90) and (
usable or (d.style == "공격형" and not aware))
decisions[i] = (target_lane, yielding, defending, attacking, activate,
rng.uniform(-1, 1) * (0.002 + 0.007 * (1 - s["SEN"] / 100)))
for i, (lane, yielding, defending, attacking, activate, noise) in decisions.items():
car = cars[i]
car.target_lane, car.yielding, car.defending, car.decision_noise = lane, yielding, defending, noise
if attacking:
car.attack_left = max(car.attack_left, 1.5)
start_focus(i, "추월 기회 포착", 1.5)
if activate:
car.boost_left = 2.0
car.cooldown = 5.0
drivers[i].boosts += 1
event("부스트", i, f"{drivers[i].name} 부스트 사용")
proposed_speed = speeds.copy()
proposed_lane = lanes.copy()
for i in active:
car, d = cars[i], drivers[i]
s, rule = d.ability, STYLE_RULES[d.style]
index, remaining = locations[i]
_, kind, segment_meters, difficulty = segments[index]
segment_key = (max(0, int(positions[i] // length)), index)
entered_segment = segment_key != car.last_segment
if entered_segment:
car.last_segment = segment_key
if kind == "corner":
safe_entry = base_speed(d.stats, kind, difficulty) / 3.6
overspeed_ratio = speeds[i] / max(1.0, safe_entry)
nearby_vehicle = any(-10 < gap < 16 and abs(lanes[i] - lanes[j]) < 0.85
for j, gap in neighbours[i])
unstable = car.recovery > 0
# 코너에 들어갔다는 이유만으로 발동하지 않고 실제 제동·근접 위험을 확인합니다.
if overspeed_ratio > 1.025 or nearby_vehicle or unstable:
reaction_chance = ((0.20 + 0.0065 * s["SEN"])
* (0.55 + 0.45 * difficulty))
if rng.random() < reaction_chance:
reason = ("제동 위험 감지" if overspeed_ratio > 1.025 else
"근접 차량 대응" if nearby_vehicle else "차체 흔들림 대응")
start_focus(i, reason, 1.4)
focus = car.focus_bonus if car.focus_left > 0 else 0.0
focus_raw = focus * (RATING_MAX - RATING_MIN) / 100
control_cor = min(FOCUS_INTERNAL_MAX, s["COR"] + focus)
power_acc = min(FOCUS_INTERNAL_MAX, s["ACC"] + focus)
low, high = bounds(kind, difficulty)
target_lane = max(low, min(high, car.target_lane))
lateral = (0.55 + control_cor / 120) * dt
proposed_lane[i] = lanes[i] + max(-lateral, min(lateral, target_lane - lanes[i]))
natural = base_speed(d.stats, kind, difficulty, focus_raw, focus_raw) / 3.6
target = natural * (1 + car.form + car.decision_noise)
if kind == "corner":
inside = low if index % 2 == 0 else high
# 안쪽 주행의 거리 이점과 급회전 부담을 목표 속도에 함께 반영합니다.
inner = 1 - abs(lanes[i] - inside) / max(0.1, high - low)
target *= 1 + inner * (control_cor / 100 - 0.65) * 0.055
if remaining < min(160, segment_meters * 0.35):
# ACC는 코너 자체보다 탈출 가속에서 더 크게 드러납니다.
effective_acc = min(FOCUS_RATING_MAX, d.stats["ACC"] + focus_raw)
target *= 1 + 0.002 * (effective_acc - 50)
front = sorted([(gap, j) for j, gap in neighbours[i]
if 4.5 < gap < 55 and abs(lanes[i] - lanes[j]) < 0.70])
drafting = bool(front) and kind == "straight" and speeds[i] > 15
draft_strength = 0.0
if drafting:
draft_strength = max(0.0, min(1.0, (55 - front[0][0]) / 50.5))
target *= 1.055 + 0.025 * draft_strength
d.draft_seconds += dt
car.energy = min(100, car.energy + 3.5 * dt)
if car.boost_left > 0 and car.energy > 0:
# ACC가 현재 기본 성능을 증폭합니다. SEN은 FOCUS 발동을 통해 간접 강화합니다.
target *= 1.10 + 0.0012 * power_acc
car.energy = max(0, car.energy - 22 * dt)
else:
car.energy = min(100, car.energy + 3 * dt)
if car.yielding:
target *= 0.97
if car.defending:
target *= 0.98
if abs(proposed_lane[i] - lanes[i]) > 0.001:
target *= 0.986 + 0.006 * control_cor / 100
# 다음 코너의 안전 속도를 보고 제동합니다. 짧은 연속 구간도 앞서 확인합니다.
preview_distance = remaining
for offset in range(1, len(segments) + 1):
nxt = segments[(index + offset) % len(segments)]
if preview_distance > 220:
break
if nxt[1] == "corner":
safe = base_speed(d.stats, nxt[1], nxt[3]) / 3.6
brake = (11 + control_cor * 0.06) * rule["brake"] * (0.9 + s["SEN"] / 1000)
allowed = math.sqrt(safe * safe + 2 * brake * max(0, preview_distance - 3))
target = min(target, allowed)
preview_distance += nxt[2]
if entered_segment:
if kind == "corner" and incidents:
overspeed = max(0, speeds[i] / natural - 1.025)
risk = (0.012 + 0.12 * difficulty * (1 - control_cor / 100) * (1 - s["SEN"] / 140)
+ overspeed * 1.5) * rule["risk"]
if rng.random() < min(0.6, risk):
car.recovery = rng.uniform(0.8, 2.0) * (1.5 - (control_cor + s["SEN"]) / 200)
car.speed *= 0.75 + control_cor / 700
proposed_lane[i] += (0.22 if index % 2 == 0 else -0.22)
d.errors += 1
event("실수", i, f"{d.name} 코너 진입 실수 · 바깥으로 밀리며 감속")
if car.recovery > 0:
target *= 0.70 + (control_cor + s["SEN"]) / 1000
# SPD가 평균 가속의 바탕이고 ACC가 그 출력을 곱해 증폭합니다.
# 정지 출발의 유불리가 전체 레이스까지 과도하게 누적되지 않도록 범위를 압축합니다.
acceleration = (5.1 + 0.012 * s["SPD"]) * (0.82 + 0.0036 * power_acc)
if drafting:
acceleration *= 1.08 + 0.04 * draft_strength
if kind == "corner" and remaining < min(160, segment_meters * 0.35):
acceleration *= 1.05 + 0.001 * power_acc
if car.boost_left > 0:
acceleration *= 1.7
if car.attack_left > 0:
acceleration *= 1.08 + 0.0012 * power_acc
v = car.speed
proposed_speed[i] = max(0, v + max(-(11 + control_cor * 0.06) * dt,
min(acceleration * dt, target - v)))
car.boost_left = max(0, car.boost_left - dt)
car.cooldown = max(0, car.cooldown - dt)
car.attack_left = max(0, car.attack_left - dt)
car.focus_left = max(0, car.focus_left - dt)
car.focus_cooldown = max(0, car.focus_cooldown - dt)
car.recovery = max(0, car.recovery - dt)
proposed_lane[i] = max(low, min(high, proposed_lane[i]))
# 예상 위치로 접촉을 판정하고 두 차에 동시에 감속과 횡방향 충격을 적용합니다.
impact_scale = [1.0] * 8
lateral_push = [0.0] * 8
for ix, i in enumerate(active):
for j in active[ix + 1:]:
gap = relative(positions[i], positions[j])
next_gap = gap + (proposed_speed[j] - proposed_speed[i]) * dt
separation = min(abs(lanes[i] - lanes[j]), abs(proposed_lane[i] - proposed_lane[j]))
close = min(abs(gap), abs(next_gap)) < 5.1
closing = abs(next_gap) < abs(gap) - 0.12
merging = abs(proposed_lane[i] - proposed_lane[j]) < abs(lanes[i] - lanes[j]) - 0.02
danger = closing or merging or abs(next_gap) < 3.5
if incidents and now > 1 and max(speeds[i], speeds[j]) > 4 and close and danger and separation < 0.63 and now >= pair_cooldown.get((i, j), 0):
# 초당 접촉 발생률을 한 시뮬레이션 간격의 확률로 변환합니다.
sense = (drivers[i].ability["SEN"] + drivers[j].ability["SEN"]) / 2
if rng.random() < 1 - math.exp(-(0.8 - sense / 160) * dt):
pair_cooldown[i, j] = now + 2.5
evaders = [k for k in (i, j)
if cars[k].focus_cooldown <= 0 and
rng.random() < 0.05 + 0.0035 * drivers[k].ability["SEN"]]
if evaders:
responder = max(evaders, key=lambda k: drivers[k].ability["SEN"])
other = j if responder == i else i
start_focus(responder, "접촉 위기 대응", 1.0)
direction = 1 if lanes[responder] >= lanes[other] else -1
if lanes[responder] == lanes[other]:
direction = 1 if responder == i else -1
lateral_push[responder] += direction * 0.32
impact_scale[responder] *= 0.99
event("회피", responder,
f"{drivers[responder].name} 순간 회피 · {drivers[other].name} 접촉 방지")
continue
for own, other in ((i, j), (j, i)):
s = drivers[own].ability
own_focus = cars[own].focus_bonus if cars[own].focus_left > 0 else 0
other_focus = cars[other].focus_bonus if cars[other].focus_left > 0 else 0
own_acc = min(FOCUS_INTERNAL_MAX, s["ACC"] + own_focus)
other_acc = min(FOCUS_INTERNAL_MAX, drivers[other].ability["ACC"] + other_focus)
control_cor = min(FOCUS_INTERNAL_MAX, s["COR"] + own_focus)
resistance = 1 + (other_acc - own_acc) / 180
loss = (0.11 + rng.random() * 0.09) * resistance * (1.15 - s["SEN"] / 200)
impact_scale[own] *= 1 - loss
direction = 1 if lanes[own] >= lanes[other] else -1
if lanes[own] == lanes[other]:
direction = 1 if own == i else -1
lateral_push[own] += direction * 0.20 * resistance
cars[own].recovery = max(cars[own].recovery, 0.7 * (1.4 - (control_cor + s["SEN"]) / 200))
drivers[own].contacts += 1
event("접촉", i, f"{drivers[i].name} ↔ {drivers[j].name} 몸싸움 · 감속/차체 흔들림")
for i in active:
proposed_speed[i] *= impact_scale[i]
index, _ = locations[i]
low, high = bounds(segments[index][1], segments[index][3])
proposed_lane[i] = max(low, min(high, proposed_lane[i] + lateral_push[i]))
# 같은 주행선에서는 앞차를 통과할 수 없습니다. 앞차 감속은 뒤차에도 전파됩니다.
new_positions = [positions[i] + proposed_speed[i] * dt for i in range(8)]
blocked = set()
for _ in range(8):
changed = False
for i in active:
for j, gap in neighbours[i]:
lane_overlap = min(abs(lanes[i] - lanes[j]), abs(proposed_lane[i] - proposed_lane[j])) < 0.60
if 0 < gap < 20 and lane_overlap:
# 원래 여유가 부족하면 후진시키지 않고, 더 가까워지는 것만 막습니다.
safe_gap = min(4.5, gap)
cap = positions[i] + gap - safe_gap + (new_positions[j] - positions[j])
if new_positions[i] > cap + 1e-9:
new_positions[i] = max(positions[i], cap)
blocked.add(i)
changed = True
if not changed:
break
for i in active:
car, d = cars[i], drivers[i]
if i in blocked:
d.blocked_seconds += dt
car.speed = max(0, (new_positions[i] - positions[i]) / dt)
car.lane = proposed_lane[i]
car.position = new_positions[i]
# 프레임 사이의 결승선 통과 시각을 보간해 밀리초 단위로 기록합니다.
while len(d.laps_ms) < lap_count and car.position >= (len(d.laps_ms) + 1) * length:
crossing = (len(d.laps_ms) + 1) * length
fraction = (crossing - positions[i]) / max(1e-9, car.position - positions[i])
crossing_ms = round((now + dt * fraction) * 1000)
d.laps_ms.append(crossing_ms - sum(d.laps_ms))
if len(d.laps_ms) == lap_count:
d.total_ms = crossing_ms
car.position = finish
event("완주", i, f"{d.name} 결승선 통과 · {clock(crossing_ms)}", crossing_ms)
# 완주한 차와의 순위 역전·출발 직후 정렬·동일 순위 진동은 추월 집계에서 제외합니다.
for ix, i in enumerate(active):
for j in active[ix + 1:]:
if drivers[i].total_ms or drivers[j].total_ms:
continue
delta = cars[i].position - cars[j].position
if abs(delta) < 3:
continue
leader = i if delta > 0 else j
old = pair_leader.get((i, j))
pair_leader[i, j] = leader
if old is not None and old != leader and now > 3 and now >= pass_cooldown.get((i, j), 0):
pass_cooldown[i, j] = now + 2.0
drivers[leader].overtakes += 1
event("추월", leader, f"{drivers[leader].name} → {drivers[old].name} 추월 성공")
steps += 1
ranked = assign_points(drivers, config.get("points", DEFAULT_POINTS))
teams = {t["name"]: {"points": sum(d.points for d in drivers if d.team == t["name"]),
"time_ms": sum(d.total_ms for d in drivers if d.team == t["name"])}
for t in config["teams"]}
winner, reason = decide_winner(teams)
return {"track": track, "seed": seed, "drivers": ranked, "teams": teams,
"winner": winner, "reason": reason, "events": events, "event_counts": event_counts,
"grid": [{"team": d.team, "name": d.name, "row": int(-grid[i][0] / 7) + 1,
"lane": int(grid[i][1] / 2) + 1} for i, d in enumerate(drivers)],
"simulation_steps": steps}
def decide_winner(teams):
ordered = sorted(teams, key=lambda team: (-teams[team]["points"], teams[team]["time_ms"]))
a, b = ordered
if not math.isclose(teams[a]["points"], teams[b]["points"], rel_tol=0, abs_tol=1e-9):
return a, "팀 포인트"
if teams[a]["time_ms"] != teams[b]["time_ms"]:
return a, "포인트 동률 → 팀 합계 시간"
return None, "포인트와 팀 합계 시간 모두 동률"
def clock(ms):
minutes, remainder = divmod(ms, 60000)
seconds, milliseconds = divmod(remainder, 1000)
return f"{minutes}:{seconds:02d}.{milliseconds:03d}"
def table(headers, rows, align=None):
def width(value):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in str(value))
cells = [[str(c) for c in row] for row in [headers] + rows]
widths = [max(width(row[i]) for row in cells) for i in range(len(headers))]
align = align or ["left"] * len(headers)
for index, row in enumerate(cells):
padded = []
for i, (c, w) in enumerate(zip(row, widths)):
spaces = " " * (w - width(c))
padded.append(spaces + c if align[i] == "right" else c + spaces)
print(" ".join(padded))
if index == 0:
print(" ".join("─" * w for w in widths))
def print_result(result, game, show_events):
track, drivers = result["track"], result["drivers"]
length = sum(segment[2] for segment in track["segments"])
laps = len(drivers[0].laps_ms)
total_distance = length * laps / 1000
rule = "팀 포인트" if result["reason"] == "팀 포인트" else result["reason"]
print("\n" + "═" * 78)
print(f" IDL RACING LEAGUE · RACE {game:02d}")
print("═" * 78)
print(f" 경기장 {track['name']} | {laps} LAPS | {total_distance:.3f} km")
print(f" 시드 {result['seed']} | 판정 기준: {rule}")
print("─" * 78)
print(" FINAL CLASSIFICATION · 개인 최종 순위")
print("─" * 78)
table(["POS", "TEAM", "DRIVER", "TIME / GAP", "BEST LAP", "PTS", "ERR/CON"],
[[d.rank, d.team, d.name,
clock(d.total_ms) if d.rank == 1 else f"+{(d.total_ms - drivers[0].total_ms) / 1000:.3f}",
clock(min(d.laps_ms)), f"{d.points:g}", f"{d.errors}/{d.contacts}"] for d in drivers],
["right", "left", "left", "right", "right", "right", "right"])
team_rows = []
ordered_teams = sorted(result["teams"],
key=lambda team: (-result["teams"][team]["points"], result["teams"][team]["time_ms"]))
for rank, team in enumerate(ordered_teams, 1):
score = result["teams"][team]
members = sorted((d for d in drivers if d.team == team), key=lambda d: d.rank)
finishes = "-".join(str(d.rank) for d in members)
breakdown = "+".join(f"{d.points:g}" for d in members)
team_rows.append([rank, team, finishes, breakdown, f"{score['points']:g}", clock(score["time_ms"])])
print("\n TEAM CLASSIFICATION · 팀 경기 결과")
print("─" * 78)
table(["RK", "TEAM", "FINISH", "POINTS", "TOTAL", "TEAM TIME"], team_rows,
["right", "left", "left", "left", "right", "right"])
print("─" * 78)
if result["winner"]:
margin = result["teams"][ordered_teams[0]]["points"] - result["teams"][ordered_teams[1]]["points"]
detail = f"{margin:g}점 차" if margin else result["reason"]
print(f" WINNER {result['winner']} · {detail}")
else:
print(" RESULT 무승부")
fastest = min(drivers, key=lambda d: min(d.laps_ms))
fastest_lap = min(range(len(fastest.laps_ms)), key=lambda i: fastest.laps_ms[i]) + 1
top_overtakes = max(d.overtakes for d in drivers)
pass_leaders = ", ".join(d.name for d in drivers if d.overtakes == top_overtakes)
counts = result.get("event_counts", {})
print("\n RACE NOTES")
print(f" 최고 랩 {fastest.name} ({fastest.team}) · LAP {fastest_lap} · {clock(fastest.laps_ms[fastest_lap - 1])}")
print(f" 최다 추월 {pass_leaders} · {top_overtakes}회")
print(f" 경기 사건 추월 {counts.get('추월', 0)} · 접촉 {counts.get('접촉', 0)} · 회피 {counts.get('회피', 0)} · FOCUS {counts.get('집중', 0)}")
if show_events:
print("\n DRIVER STATISTICS")
print("─" * 78)
table(["DRIVER", "STYLE", "OVT", "BST", "FOC", "YLD", "DEF", "DRAFT", "HELD"],
[[d.name, d.style, d.overtakes, d.boosts, d.focuses, d.yields, d.defenses,
f"{d.draft_seconds:.1f}s", f"{d.blocked_seconds:.1f}s"] for d in drivers],
["left", "left", "right", "right", "right", "right", "right", "right", "right"])
print("\n LAP TIMES")
print("─" * 78)
table(["DRIVER"] + [f"L{i + 1}" for i in range(laps)],
[[d.name] + [clock(ms) for ms in d.laps_ms] for d in drivers],
["left"] + ["right"] * laps)
print("\n RACE CONTROL LOG")
print("─" * 78)
stored = result["events"]
if show_events == "summary":
stored = [event for event in stored if event["type"] != "부스트"]
if len(stored) > 12:
stored = [stored[round(i * (len(stored) - 1) / 11)] for i in range(12)]
for event in stored:
print(f" {clock(event['time_ms'])} L{event['lap']} {event['segment']} | {event['text']}")
if len(stored) < sum(counts.values()):
print(f" 표시 {len(stored)}건 / 전체 {sum(counts.values())}건")
print("═" * 78)
def export_csv(path, results):
max_laps = max(len(d.laps_ms) for r in results for d in r["drivers"])
headers = ["경기", "맵", "시드", "순위", "팀", "선수", *STATS, "완주_초", "최고랩_초", "포인트", "실수", "접촉",
"성향", "추월", "부스트", "FOCUS", "양보시도", "수비시도", "슬립스트림_초", "막힘_초"]
headers += [f"LAP{i + 1}_초" for i in range(max_laps)]
with Path(path).open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(headers)
for game, result in enumerate(results, 1):
for d in result["drivers"]:
writer.writerow([game, result["track"]["name"], result["seed"], d.rank, d.team, d.name,
*[d.stats[s] for s in STATS], f"{d.total_ms / 1000:.3f}",
f"{min(d.laps_ms) / 1000:.3f}", f"{d.points:g}", d.errors, d.contacts,
d.style, d.overtakes, d.boosts, d.focuses, d.yields, d.defenses, f"{d.draft_seconds:.1f}", f"{d.blocked_seconds:.1f}",
*[f"{ms / 1000:.3f}" for ms in d.laps_ms],
*[""] * (max_laps - len(d.laps_ms))])
def normalize(text):
return "".join(c for c in text.casefold() if c.isalnum())
def resolve_team(text):
names = list(ROSTERS)
if text.isdecimal() and 1 <= int(text) <= len(names):
return names[int(text) - 1]
aliases = {normalize(t): t for t in names}
aliases.update({normalize(alias): target for alias, target in TEAM_ALIASES.items()})
key = normalize(text)
if key not in aliases:
raise ValueError(f"등록되지 않은 팀: {text}. 사용 가능: {', '.join(names)}")
return aliases[key]
def expected_lap(player, track):
"""선발용 근사 점수: 기본 구간 기록 + 코너 위험 손실.
제동·부스트·교통·출발 배치를 제외하므로 실제 랩타임 예측값은 아닙니다.
"""
seconds = 0.0
ability = scale_stats(player)
for _, kind, meters, difficulty in track["segments"]:
seconds += meters / (base_speed(player, kind, difficulty) / 3.6)
if kind == "corner":
chance = 0.012 + 0.12 * difficulty * (1 - ability["COR"] / 100) * (1 - ability["SEN"] / 140)
recovery = 1.4 * (1.5 - (ability["COR"] + ability["SEN"]) / 200)
mean_loss = recovery * (0.30 - (ability["COR"] + ability["SEN"]) / 1000) + 0.25
seconds += chance * mean_loss
return seconds
def expected_race(player, track):
"""자동 선발용 예상 총기록. 정지 출발 가속 손실까지 포함합니다."""
ability = scale_stats(player)
acceleration = ((5.1 + 0.012 * ability["SPD"])
* (0.82 + 0.0036 * ability["ACC"]))
first = track["segments"][0]
first_target = base_speed(player, first[1], first[3]) / 3.6
# 목표속도까지 선형 가속할 때 즉시 목표속도로 달리는 경우보다 생기는 근사 손실입니다.
launch_loss = first_target / (2 * acceleration)
return expected_lap(player, track) * track["laps"] + launch_loss
def select_lineup(team, track, manual=""):
roster = [{"name": row[0], **dict(zip(STATS, row[1:]))} for row in ROSTERS[team]]
if len(roster) < 4:
raise ValueError(f"{team}: 등록 선수가 4명 이상이어야 합니다.")
if manual.strip() and normalize(manual) not in ("auto", "자동"):
requested = [p.strip() for p in manual.replace(",", ",").split(",")]
if len(requested) != 4 or len(set(requested)) != 4:
raise ValueError(f"{team}: 서로 다른 선수 4명을 쉼표로 구분하세요.")
lookup = {p["name"]: p for p in roster}
for player in requested:
if player not in lookup:
raise ValueError(f"{team}에 {player} 선수가 없습니다. 명단: {', '.join(lookup)}")
chosen = [lookup[p] for p in requested]
else:
# 예상 총기록이 같으면 이름순으로 선발하여 등록 순서에 영향받지 않습니다.
chosen = sorted(roster, key=lambda p: (expected_race(p, track), p["name"]))[:4]
picked = {p["name"] for p in chosen}
return {"name": team, "players": chosen}, [p["name"] for p in roster if p["name"] not in picked]
def apply_styles(team, value):
aliases = {"공격": "공격형", "균형": "균형형", "안정": "안정형", "팀플레이": "팀플레이형"}
def style(text):
text = text.strip()
text = aliases.get(text, text)
if text not in STYLE_RULES:
raise ValueError("성향은 공격형/균형형/안정형/팀플레이형 중 하나입니다.")
return text
if ":" not in value:
team["style"] = style(value)
return
roster_names = {row[0] for row in ROSTERS[team["name"]]}
selections = {}
for spec in value.split(","):
parts = spec.split(":", 1)
if len(parts) != 2 or parts[0].strip() not in roster_names:
raise ValueError(f"{team['name']} 개인 성향 형식: 선수명:공격형,선수명:안정형")
player = parts[0].strip()
if player in selections:
raise ValueError(f"성향을 중복 지정했습니다: {player}")
selections[player] = style(parts[1])
for player in team["players"]:
player["style"] = selections.get(player["name"], "균형형")
def online_main():
"""myCompiler의 미리 입력된 stdin을 읽습니다. 별도 설정 파일이 필요 없습니다."""
if sys.stdin.isatty():
print("4v4 IDOL RACING | 등록 팀")
for i, team in enumerate(ROSTERS, 1):
print(f" {i}. {team} ({len(ROSTERS[team])}명)")
try:
lines = [input("A팀: ").strip(), input("B팀: ").strip()]
option = input("맵 (엔터: harbor, 전체 맵: all): ").strip()
if option:
lines.append(option)
except EOFError:
print("입력을 종료했습니다.")
return 0
else:
lines = [line.strip() for line in sys.stdin.read().splitlines() if line.strip()]
if len(lines) < 2:
print("4v4 IDOL RACING | 등록 팀")
for i, team in enumerate(ROSTERS, 1):
print(f" {i}. {team} ({len(ROSTERS[team])}명)")
print("\n표준 입력(stdin)에 대결할 팀을 한 줄에 하나씩 넣고 실행하세요.")
print("예시:\naespa\nIVE")
print("선택 설정: 세 번째 줄부터 맵=all, 시드=42, A=선수1,선수2,선수3,선수4 등")
return 0 if not lines else 1
try:
team_a, team_b = map(resolve_team, lines[:2])
if team_a == team_b:
raise ValueError("서로 다른 두 팀을 선택하세요.")
opts = {"map": "harbor", "a": "", "b": "", "seed": "", "rounds": "1", "laps": "", "events": "off",
"astyle": "균형형", "bstyle": "균형형"}
option_aliases = {"맵": "map", "시드": "seed", "반복": "rounds", "랩": "laps", "로그": "events",
"a성향": "astyle", "b성향": "bstyle"}
seen = set()
for index, line in enumerate(lines[2:]):
if "=" not in line:
if index == 0:
key, value = "map", line
else:
raise ValueError(f"설정은 이름=값 형식으로 입력하세요: {line}")
else:
key, value = (part.strip() for part in line.split("=", 1))
key = key.casefold()
key = option_aliases.get(key, key)
if key not in opts:
raise ValueError(f"알 수 없는 설정: {key}. 맵/A/B/시드/반복/랩/로그/A성향/B성향을 사용하세요.")
if key in seen:
raise ValueError(f"설정이 중복되었습니다: {key}")
seen.add(key)
opts[key] = value
tracks = example_config()["tracks"]
map_aliases = {str(i): t["id"] for i, t in enumerate(tracks, 1)}
map_aliases.update({normalize(t["name"]): t["id"] for t in tracks})
map_aliases.update({"직선": "speedway", "균형": "harbor", "산악": "mountain", "시가지": "city", "전체": "all"})
map_key = normalize(opts["map"])
map_key = map_aliases.get(map_key, map_key)
selected = tracks if map_key == "all" else [t for t in tracks if t["id"] == map_key]
if not selected:
raise ValueError("맵은 speedway(1), harbor(2), mountain(3), city(4), all 중 하나입니다.")
rounds = int(opts["rounds"])
laps = int(opts["laps"]) if opts["laps"] else None
if not 1 <= rounds <= 100 or (laps is not None and not 1 <= laps <= 100):
raise ValueError("반복 횟수와 랩 수는 1~100이어야 합니다.")
event_value = opts["events"].casefold()
if event_value not in ("on", "off", "true", "false", "1", "0", "켜기", "끄기", "summary", "요약"):
raise ValueError("로그는 켜기/끄기/요약 중 하나로 지정하세요.")
show_events = "summary" if event_value in ("summary", "요약") else event_value in ("on", "true", "1", "켜기")
seed = int(opts["seed"]) if opts["seed"] else random.SystemRandom().randrange(2**32)
# 모든 맵의 수동 선발 및 능력치 검증을 먼저 끝냅니다.
matchups = []
for track in selected:
a, bench_a = select_lineup(team_a, track, opts["a"])
b, bench_b = select_lineup(team_b, track, opts["b"])
apply_styles(a, opts["astyle"])
apply_styles(b, opts["bstyle"])
config = {"teams": [a, b], "tracks": [track], "points": DEFAULT_POINTS}
validate(config)
matchups.append((track, config, (bench_a, bench_b)))
print("\n" + "═" * 78)
print(" IDL RACING LEAGUE · MATCH CARD")
print("═" * 78)
print(f" {team_a} vs {team_b}")
print(f" 시리즈 시드 {seed} | 배점 " + "-".join(str(p) for p in DEFAULT_POINTS))
print("═" * 78)
totals = {t: {"points": 0.0, "time_ms": 0, "wins": 0} for t in (team_a, team_b)}
game = 0
for _ in range(rounds):
for track, config, benches in matchups:
for team, bench in zip(config["teams"], benches):
names = " · ".join(p["name"] for p in team["players"])
reserve = ", ".join(bench) or "없음"
print(f" ENTRY {team['name']}: {names} | RES {reserve}")
result = simulate_race(config, track, seed + game, laps)
game += 1
print_result(result, game, show_events)
for team, score in result["teams"].items():
totals[team]["points"] += score["points"]
totals[team]["time_ms"] += score["time_ms"]
totals[team]["wins"] += int(result["winner"] == team)
if game > 1:
series_rows = sorted(totals.items(), key=lambda x: (-x[1]["points"], x[1]["time_ms"]))
print("\n" + "═" * 78)
print(f" SERIES CLASSIFICATION · {game} RACES")
print("═" * 78)
table(["RK", "TEAM", "POINTS", "WINS", "TEAM TIME"],
[[rank, t, f"{score['points']:g}", score["wins"], clock(score["time_ms"])]
for rank, (t, score) in enumerate(series_rows, 1)],
["right", "left", "right", "right", "right"])
winner, reason = decide_winner(totals)
print("─" * 78)
print(f" SERIES WINNER {winner or '무승부'} · {reason}")
print("═" * 78)
return 0
except (ValueError, KeyError, TypeError, RuntimeError) as exc:
print(f"\n입력 오류: {exc}")
return 1
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if not argv:
return online_main()
parser = argparse.ArgumentParser(description="능력치 기반 4v4 레이싱 · 외부 패키지 불필요",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="예: python racing_simulator.py --maps all --seed 42\n"
"설정 만들기: --write-example my_league.json\n"
"맵 확인: --list-maps")
parser.add_argument("--config", help="팀·선수·맵 JSON 경로")
parser.add_argument("--write-example", metavar="PATH", help="수정할 수 있는 예시 JSON 생성 후 종료")
parser.add_argument("--maps", nargs="+", default=None, help="맵 ID들 또는 all (기본: 첫 번째 맵)")
parser.add_argument("--list-maps", action="store_true", help="맵 목록과 구간 표시")
parser.add_argument("--rounds", type=int, default=1, help="선택한 맵 목록 반복 횟수, 1~100")
parser.add_argument("--laps", type=int, help="모든 맵 랩 수 덮어쓰기, 1~100")
parser.add_argument("--seed", type=int, help="재현용 정수; 생략하면 무작위 시드를 출력")
parser.add_argument("--points", help="1~8위 배점. 예: 10,7,5,3,2,1,0,0")
parser.add_argument("--no-incidents", action="store_true", help="코너 실수·접촉 비활성화")
parser.add_argument("--events", action="store_true", help="전체 경기 로그 표시")
parser.add_argument("--csv", metavar="PATH", help="경기 결과 CSV 저장")
args = parser.parse_args(argv)
try:
if args.write_example:
# 기존 설정 파일을 실수로 덮어쓰지 않습니다.
with Path(args.write_example).open("x", encoding="utf-8") as f:
json.dump(example_config(), f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"설정 생성: {args.write_example}")
return 0
config = json.loads(Path(args.config).read_text(encoding="utf-8-sig")) if args.config else example_config()
if args.points is not None:
config["points"] = [float(p.strip()) for p in args.points.split(",")]
validate(config)
if not 1 <= args.rounds <= 100 or (args.laps is not None and not 1 <= args.laps <= 100):
raise ValueError("반복 횟수와 랩 수는 1~100이어야 합니다.")
tracks = {t["id"]: t for t in config["tracks"]}
if args.list_maps:
for track in tracks.values():
print(f"\n{track['id']} | {track['name']} | 기본 {track['laps']}랩")
print(track.get("description", ""))
table(["구간", "종류", "길이(m)", "난도"], track["segments"])
return 0
selected = args.maps or [next(iter(tracks))]
if selected == ["all"]:
selected = list(tracks)
for key in selected:
if key not in tracks:
raise ValueError(f"알 수 없는 맵 {key}. 사용 가능: {', '.join(tracks)} 또는 all")
seed = args.seed if args.seed is not None else random.SystemRandom().randrange(2**32)
print(f"4v4 RACING | 시리즈 시드: {seed}")
print("1~8위 배점: " + " / ".join(f"{p:g}" for p in config.get("points", DEFAULT_POINTS)))
print("순위: 전체 랩 합계 시간 → 개인 포인트 → 팀 합산")
results = []
totals = {t["name"]: {"points": 0.0, "time_ms": 0, "wins": 0} for t in config["teams"]}
for _ in range(args.rounds):
for key in selected:
result = simulate_race(config, tracks[key], seed + len(results), args.laps, not args.no_incidents)
results.append(result)
print_result(result, len(results), args.events)
for team, score in result["teams"].items():
totals[team]["points"] += score["points"]
totals[team]["time_ms"] += score["time_ms"]
totals[team]["wins"] += int(result["winner"] == team)
if len(results) > 1:
print(f"\n시리즈 최종 결과 · {len(results)}경기")
table(["팀", "누적 포인트", "누적 팀 기록", "승수"],
[[team, f"{score['points']:g}", clock(score["time_ms"]), score["wins"]]
for team, score in sorted(totals.items(), key=lambda x: (-x[1]["points"], x[1]["time_ms"]))])
winner, reason = decide_winner(totals)
print(f"시리즈 승리: {winner or '무승부'} ({reason})")
if args.csv:
export_csv(args.csv, results)
print(f"\nCSV 저장: {args.csv}")
return 0
except (ValueError, KeyError, TypeError, OSError, OverflowError, RuntimeError) as exc:
parser.error(f"설정 또는 파일 오류: {exc}")
# myCompiler 호환: __main__ 조건문 없이 파일 실행 즉시 시작합니다.
sys.exit(main())
To embed this project on your website, copy the following code and paste it into your website's HTML: