#!/usr/bin/env python3
"""4v4 IDOL RACING v6.3 · 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명이 출전합니다.
등록 명단은 15팀 79명이며 ROSTERS에서 추가·수정할 수 있습니다.

엔진과 규칙:
  • 0.1초마다 누적 거리·속도·횡방향 위치·부스트 잔량을 갱신합니다.
    0.5초마다 추월, 수비, 양보, 부스트 사용을 판단합니다.
  • 직선과 코너 폭이 달라집니다. 같은 주행선에서 앞차를 통과할 수 없습니다.
    앞차에 가까워지기 전에 빈 차선으로 이동해 추월을 준비합니다.
  • 직선에서 앞차 뒤 4.5~55m, 비슷한 주행선이면 슬립스트림으로 최고속도
    5.5~8.0%와 가속력이 증가하고 부스트가 추가 충전됩니다. 팀원 뒤에서도 적용합니다.
    이 보너스는 현재 1위 1.00배부터 8위 1.35배까지 커지며, 순위가 바뀌면
    1.5초에 걸쳐 새 배율로 변합니다. 앞차가 없으면 순위 보정도 적용되지 않습니다.
  • 부스트는 2초 지속, 게이지 소모와 재사용 대기 시간이 있습니다.
  • COR/SEN/성향에 따라 코너 전 제동 시점이 달라집니다. 늦게 진입하면
    추월 기회가 있지만 바깥으로 밀리거나 출구 속도가 떨어질 수 있습니다.
  • 접촉은 두 선수의 속도·횡방향 위치·회복 상태를 바꿉니다.
    ACC는 밀림 저항, COR는 차체 제어, SEN은 접촉 위험 및 손실에 관여합니다.
    팀원끼리도 접촉할 수 있습니다. 고정 시간 벌점은 없습니다.
  • ACC는 기본 속도를 독립적으로 만들지 않고 가속·최고속·코너 탈출·부스트와
    몸싸움에서 SPD/COR의 결과를 증폭합니다. 낮은 SPD를 완전히 대신할 수 없습니다.
  • SEN은 추월·실제 코너 진입 위험·충돌 위기에서 FOCUS를 발동합니다. 발동 중
    COR/ACC가 4~10점 상승하며 일시적으로만 90까지 허용됩니다. SEN이 높을수록
    발동률과 상승량이 커집니다.
  • 출발은 정지 상태 2열×4행. 매 행에 양 팀이 한 명씩 서며 선수/좌우는 시드로
    추첨합니다. 첫 랩은 출발 위치 차이와 정지 출발 시간을 포함합니다.
  • 선수마다 경기 전체에 유지되는 당일 컨디션이 있습니다. 평균 근처의 컨디션이
    가장 자주 나오며 최대 ±3.0%까지 목표 속도에 영향을 줍니다.
  • 각 랩 결승선 통과 시각을 보간해 밀리초로 기록합니다. 마지막 랩까지의
    합계 시간으로 개인 순위, 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),
        ("윈터", 70, 65, 60, 70), ("닝닝", 65, 60, 60, 75),
    ],
    "IVE": [
        ("안유진", 70, 65, 75, 65), ("가을", 50, 65, 55, 45),
        ("레이", 60, 50, 70, 50), ("장원영", 80, 55, 80, 45),
        ("리즈", 65, 55, 60, 65), ("이서", 60, 60, 65, 50),
    ],
    "NewJeans": [
        ("민지", 70, 60, 60, 40), ("하니", 60, 70, 65, 65),
        ("다니엘", 55, 60, 50, 55), ("해린", 70, 65, 60, 60),
        ("혜인", 45, 60, 55, 55),
    ],
    "LE SSERAFIM": [
        ("사쿠라", 55, 55, 55, 45), ("김채원", 60, 70, 65, 65),
        ("허윤진", 55, 60, 55, 65), ("카즈하", 70, 65, 65, 45),
        ("홍은채", 65, 70, 60, 45),
    ],
    "ITZY": [
        ("예지", 70, 70, 65, 60), ("리아", 60, 50, 55, 65),
        ("류진", 60, 65, 60, 50), ("채령", 45, 75, 65, 55),
        ("유나", 75, 65, 75, 45),
    ],
    "i-dle": [
        ("미연", 70, 50, 60, 70), ("민니", 55, 55, 60, 65),
        ("소연", 40, 65, 45, 55), ("우기", 70, 65, 55, 65),
        ("슈화", 60, 55, 55, 40),
    ],
    "ILLIT": [
        ("윤아", 50, 65, 60, 60), ("민주", 70, 55, 60, 65),
        ("모카", 55, 65, 55, 50), ("원희", 65, 60, 65, 60),
        ("이로하", 65, 75, 60, 50),
    ],
    "NMIXX": [
        ("릴리", 55, 60, 60, 80), ("해원", 60, 65, 50, 75),
        ("설윤", 80, 55, 70, 70), ("배이", 60, 60, 70, 50),
        ("지우", 70, 70, 55, 55), ("규진", 60, 75, 65, 60),
    ],
    "BABYMONSTER": [
        ("루카", 45, 75, 55, 45), ("파리타", 55, 55, 70, 60),
        ("아사", 60, 70, 65, 55), ("아현", 65, 65, 70, 70),
        ("로라", 65, 60, 60, 65), ("치키타", 45, 65, 55, 60),
    ],
    "Hearts2Hearts": [
        ("지우", 65, 65, 70, 50), ("카르멘", 60, 55, 70, 65),
        ("유하", 60, 70, 60, 65), ("스텔라", 60, 55, 60, 60),
        ("주은", 55, 75, 55, 50), ("에이나", 65, 65, 50, 55),
        ("이안", 75, 60, 70, 50), ("예온", 45, 55, 50, 60),
    ],
    "KISS OF LIFE": [
        ("쥴리", 50, 70, 60, 55), ("벨", 65, 55, 65, 70),
        ("나띠", 60, 75, 80, 55), ("하늘", 65, 60, 60, 65),
    ],
    "RESCENE": [
        ("원이", 65, 45, 70, 50), ("리브", 65, 55, 60, 65),
        ("미나미", 60, 60, 60, 65), ("메이", 55, 50, 55, 50),
        ("제나", 65, 60, 60, 60),
    ],
    "KiiiKiii": [
        ("지유", 60, 65, 50, 55), ("이솔", 65, 55, 60, 50),
        ("하음", 60, 75, 65, 50), ("수이", 70, 55, 65, 60),
        ("키야", 60, 55, 70, 60),
    ],
    "MEOVV": [
        ("수인", 60, 70, 60, 55), ("가원", 55, 65, 65, 65),
        ("안나", 80, 55, 70, 50), ("나린", 45, 65, 55, 65),
        ("엘라", 50, 60, 65, 60),
    ],
    "fromis_9": [
        ("송하영", 55, 75, 60, 70), ("박지원", 65, 50, 55, 80),
        ("이채영", 70, 65, 70, 55), ("이나경", 75, 60, 60, 60),
        ("백지헌", 60, 55, 75, 45),
    ],
}

TEAM_ALIASES = {
    "에스파": "aespa", "아이브": "IVE", "뉴진스": "NewJeans",
    "르세라핌": "LE SSERAFIM", "르쎄라핌": "LE SSERAFIM", "있지": "ITZY",
    "아이들": "i-dle", "여자아이들": "i-dle", "(여자)아이들": "i-dle", "GIDLE": "i-dle",
    "아일릿": "ILLIT", "엔믹스": "NMIXX", "베이비몬스터": "BABYMONSTER", "베몬": "BABYMONSTER",
    "하츠투하츠": "Hearts2Hearts", "하투하": "Hearts2Hearts",
    "키스오브라이프": "KISS OF LIFE", "키오프": "KISS OF LIFE",
    "리센느": "RESCENE", "키키": "KiiiKiii", "미야오": "MEOVV",
    "프로미스나인": "fromis_9", "프나": "fromis_9",
}


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.60 * (spd - 50)) * (1 + 0.0014 * (acc - 50))
    # COR가 코너 차이를 만들고, ACC의 상시 코너 보정은 작게 제한합니다.
    return ((162.5 + 0.32 * (spd - 50) + 0.56 * (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
    draft_rank_factor: float = 1.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.triangular(-0.030, 0.030, 0.0))
            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}
        finished_count = 8 - len(active)
        # 같은 거리에 있는 선수는 공동 순위의 중간값을 사용해 출발 순간의 편향을 없앱니다.
        for i in active:
            ahead_count = sum(positions[j] > positions[i] + 1e-9 for j in active if j != i)
            tied_count = sum(abs(positions[j] - positions[i]) <= 1e-9 for j in active if j != i)
            live_rank = 1 + finished_count + ahead_count + tied_count * 0.5
            wanted_factor = 1.0 + 0.05 * (live_rank - 1)
            cars[i].draft_rank_factor += (wanted_factor - cars[i].draft_rank_factor) * min(1.0, dt / 1.5)
        # 모든 판단은 같은 시각의 위치·속도 사본을 사용합니다.
        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)]
                approach = (28 if kind == "straight" else 22) + 6 * s["SEN"] / 100
                if d.style == "공격형":
                    approach += 4
                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] < approach and available:
                    # 차선을 옮기는 동안 따라잡을 수 있으므로 앞차와 붙기 전에 판단합니다.
                    # 코너에서는 폭이 좁아 실제로 앞차와 겹치지 않을 차선만 고릅니다.
                    passing_lanes = [lane for lane in available
                                     if abs(lane - lanes[front[1]]) >= 0.70]
                    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 passing_lanes and rng.random() < attack_chance:
                        target_lane = max(passing_lanes, key=lane_score)
                        attacking = abs(target_lane - lanes[i]) > 0.15
                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))
                draft_speed_bonus = (0.055 + 0.025 * draft_strength) * car.draft_rank_factor
                target *= 1 + draft_speed_bonus
                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:
                draft_accel_bonus = (0.08 + 0.04 * draft_strength) * car.draft_rank_factor
                acceleration *= 1 + draft_accel_bonus
            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())

Embed on website

To embed this project on your website, copy the following code and paste it into your website's HTML: