From 3d118fe6499162ddb7e9d832deaa406c2112db3f Mon Sep 17 00:00:00 2001 From: kswdev0 Date: Thu, 26 Mar 2026 09:49:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20mining=5Fstate=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20GLM=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `mine_resource` 함수에서 채굴 상태를 매 루프마다 반복 설정하던 로직을 제거하여, 우클릭 유지와 유사한 동작으로 최적화 - GLM API 호출 실패 시 예외 처리 로직을 개선하여, 다양한 오류 원인을 로그로 출력하고 상태 요약 기반의 폴백 로직 추가 - 새로운 연결 검사 스크립트 추가 및 README.md에 GLM API 연결 문제 디버깅 섹션 추가 - 관련 문서 및 주의사항 업데이트 --- README.md | 20 ++- action_executor.py | 11 +- ai_planner.py | 215 ++++++++++++++++++++++++++++++-- docs/plan.md | 53 +++++++- scripts/glm_connection_check.py | 87 +++++++++++++ 5 files changed, 366 insertions(+), 20 deletions(-) create mode 100644 scripts/glm_connection_check.py diff --git a/README.md b/README.md index f951f31..629de1d 100644 --- a/README.md +++ b/README.md @@ -122,13 +122,30 @@ planner.set_goal( --- +## GLM API 연결 문제 디버깅 + +타임아웃·연결 오류가 나면 **원인을 로그로 먼저 구분**하는 것이 좋습니다. + +1. **전용 검사 스크립트** (에이전트와 동일한 `urllib` 경로): + + ```bash + ZAI_API_KEY="your-key" python scripts/glm_connection_check.py + ``` + +2. **실행 시 상세 로그**: `GLM_DEBUG=1`을 켜면 재시도마다 `[GLM 원인] …` 한 줄에 `URLError.reason`, SSL/소켓 `errno`, DNS 힌트 등이 붙고, 스택 트레이스도 출력됩니다. + +3. **자주 있는 원인**: Docker/서버에서 **외부 HTTPS(443) 차단**, **프록시 필요**( `HTTPS_PROXY` ), **DNS 실패**, **API 키 만료·오타**, **응답이 느려 타임아웃**( `GLM_HTTP_TIMEOUT_SECONDS` 증가 ). + +--- + ## 주의사항 - 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다 - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 - `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. -- `ai_planner.py`의 `AIPlanner.decide()`는 `TimeoutError`/`ConnectionError`/`urllib.error.URLError` 같은 GLM HTTP 지연/연결 오류도 재시도 후 폴백(explore)합니다. +- `ai_planner.py`의 `AIPlanner.decide()`는 `TimeoutError`/`ConnectionError`/`urllib.error.URLError` 같은 GLM HTTP 지연/연결 오류도 3회 재시도한 뒤, **상태 요약에 나온 광맥(앵커) 좌표가 있으면 `mine_resource`(먼 경우 `move` 후 채굴)로 폴백**하고, 광맥 정보가 없을 때만 `explore` 방향을 순환하며 탐색합니다(동일 방향 탐색 루프 완화). +- GLM HTTP 읽기 제한 시간은 기본 120초이며, `GLM_HTTP_TIMEOUT_SECONDS`로 조정할 수 있습니다. 광맥은 플레이어와 200타일 이상 떨어진 경우에만 폴백에서 `move`를 끼우며, 임계값은 `GLM_FALLBACK_MOVE_THRESHOLD`(기본 200)로 바꿀 수 있습니다. - `ai_planner.py`의 `_call_glm()`에서 GLM 지연 원인 분석을 위한 타이밍 로그가 출력됩니다. - `total`: 요청 시작~콘텐츠 반환까지 전체 소요 - `http_read`: HTTP 응답 본문 수신까지 소요 @@ -144,6 +161,7 @@ planner.set_goal( - 또한 `main inventory`가 비어 캐시 fallback이 동작하면, 로드된 캐시의 `items/total`도 함께 출력합니다. - `mine_resource`에서 실패한 채굴 타일 제외(`exclude`)는 Lua와 Python 양쪽에서 정수 타일 좌표(`tx, ty`) 키로 통일해, 제외한 좌표가 반복 선택되지 않도록 합니다. - 또한 채굴 시작(`mining_state`) 좌표는 정수 타일이 아니라, Lua가 찾은 실제 자원 엔티티의 `e.position`(정확 실수 좌표)을 사용해 “플레이어가 타일 위에 있는데도 즉시 채굴 감지 실패”를 줄입니다. +- `mine_resource`는 `LuaControl.mining_state`를 **해당 타깃에 대해 한 번만** 설정합니다. 이전에는 대기 루프마다 같은 값을반복 설정해 우클릭을 연타하는 것과 비슷한 동작이었으며, 이제는 인간이 우클릭을 누른 채로 두는 것에 가깝게 유지합니다. - `mine_resource`는 move 후 `p.position` 근처(반경 1.2)에서 실제로 해당 광물 엔티티가 있는지 재확인하고, 없으면 채굴을 시도하지 않고 다음 후보로 넘어갑니다. - `scan_resources()`는 패치 평균 중심(`center_x/y`)만이 아니라 플레이어 기준 가장 가까운 실제 엔티티 좌표(`anchor_x/y`)도 함께 반환하며, `summarize_for_ai()`에서는 앵커를 기준으로 거리/추천을 계산합니다. - `mine_resource`의 초기 “후보 엔티티 탐색” 반경은 패치 중심 오차를 흡수하기 위해 250으로 확장해, 이동 직후 바로 실패하는 케이스를 줄입니다. diff --git a/action_executor.py b/action_executor.py index 4d5e0d7..7f1a0fc 100644 --- a/action_executor.py +++ b/action_executor.py @@ -391,16 +391,17 @@ if res and #res > 0 then rcon.print("YES") else rcon.print("NO") end continue # 3. 현재 위치에서 채굴 시도 + # 우클릭 “유지”: mining_state를 매 루프마다 다시 쓰면 릴리즈 후 재클릭처럼 동작해 + # 진행이 끊기고 RCON도 과도하게 호출된다. 한 번만 설정하고 틱 동안 그대로 둔다. + self.rcon.lua(P + f""" +p.update_selected_entity({{x = {mine_x}, y = {mine_y}}}) +p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}} +""") stall_count = 0 last_item = self._get_item_count(ore) mined_this_tile = False for tick in range(300): # 최대 30초 - # mining_state로 “우클릭 유지”에 가까운 수동 채굴을 시뮬레이션 - self.rcon.lua(P + f""" -p.update_selected_entity({{x = {mine_x}, y = {mine_y}}}) -p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}} -""") time.sleep(0.1) if tick % 8 == 7: diff --git a/ai_planner.py b/ai_planner.py index 336aacf..bc03e9a 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -18,6 +18,7 @@ import re import time import urllib.request import urllib.error +import traceback GLM_API_URL = "https://api.z.ai/api/coding/paas/v4/chat/completions" @@ -118,6 +119,56 @@ state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션 ## 절대 중요: 순수 JSON만 출력하세요. ```json 같은 마크다운 블록, 설명 텍스트, 주석 없이 오직 { } 만.""" +def _glm_debug_enabled() -> bool: + v = os.environ.get("GLM_DEBUG", "").strip().lower() + return v in ("1", "true", "yes", "on") + + +def describe_glm_exception(exc: BaseException) -> str: + """ + GLM HTTP 호출 실패 시 원인 분류용 문자열. + - URLError.reason 안의 SSL/소켓 예외, errno 등을 함께 적어 + 'TimeoutError' 한 줄만으로는 알 수 없는 정보를 남긴다. + """ + parts: list[str] = [type(exc).__name__] + msg = str(exc).strip() + if msg: + parts.append(msg[:800]) + + if isinstance(exc, urllib.error.URLError): + r = exc.reason + parts.append(f"URLError.reason={type(r).__name__}: {r!s}"[:500]) + if isinstance(r, OSError): + parts.append(f"reason.errno={getattr(r, 'errno', None)!r}") + we = getattr(r, "winerror", None) + if we is not None: + parts.append(f"reason.winerror={we!r}") + inner = getattr(r, "__cause__", None) or getattr(r, "__context__", None) + if inner is not None: + parts.append(f"chained={type(inner).__name__}: {inner!s}"[:300]) + + if isinstance(exc, OSError) and not isinstance(exc, urllib.error.URLError): + parts.append(f"errno={exc.errno!r}") + if getattr(exc, "winerror", None) is not None: + parts.append(f"winerror={exc.winerror!r}") + + # 힌트(자주 나오는 케이스) + low = " ".join(parts).lower() + if "timed out" in low or "timeout" in low: + parts.append( + "hint=응답 수신 지연/차단 — GLM_HTTP_TIMEOUT_SECONDS 증가, " + "서버·Docker 네트워크 egress, 프록시(HTTPS_PROXY) 확인" + ) + if "connection refused" in low or "econnrefused" in low: + parts.append("hint=대상 포트 닫힘 또는 잘못된 호스트(프록시/방화벽)") + if "certificate" in low or "ssl" in low or "tls" in low: + parts.append("hint=SSL 검증 실패 — 기업 프록시/자체 CA, 시스템 시간 오차") + if "name or service not known" in low or "getaddrinfo" in low: + parts.append("hint=DNS 실패 — 컨테이너/호스트 resolv.conf") + + return " | ".join(parts) + + class AIPlanner: def __init__(self): self.api_key = os.environ.get("ZAI_API_KEY", "") @@ -126,6 +177,8 @@ class AIPlanner: self.step = 0 self.feedback_log: list[dict] = [] + # GLM 전부 실패 시 explore 방향 순환 (동일 방향 탐색 루프 완화) + self._fallback_explore_turn = 0 self.long_term_goal = ( "완전 자동화 달성: " "석탄 채굴 → 철 채굴+제련 자동화 → 구리 채굴+제련 → " @@ -154,23 +207,33 @@ class AIPlanner: raw = self._call_glm(user_message, attempt=attempt) plan = self._parse_json(raw) break - except (ValueError, json.JSONDecodeError, TimeoutError, ConnectionError, urllib.error.URLError) as e: + except ( + ValueError, + json.JSONDecodeError, + TimeoutError, + ConnectionError, + urllib.error.URLError, + OSError, + ) as e: + detail = describe_glm_exception(e) if attempt < 2: print( f"[경고] GLM 처리 실패 (시도 {attempt+1}/3): " f"{type(e).__name__} 재시도..." ) - time.sleep(1 + attempt * 2) + print(f" [GLM 원인] {detail}") + if _glm_debug_enabled(): + traceback.print_exc() + time.sleep(2 + attempt * 3) continue - print(f"[오류] GLM 처리 3회 실패. 기본 탐색 행동 사용.") - plan = { - "thinking": "API 응답 파싱 실패로 기본 탐색 수행", - "current_goal": "주변 탐색", - "actions": [ - {"action": "explore", "params": {"direction": "east", "max_steps": 200}, "reason": "자원 탐색"}, - ], - "after_this": "자원 발견 후 채굴 시작" - } + print(f"[오류] GLM 처리 3회 실패. 상태 요약 기반 휴리스틱 폴백 사용.") + print(f" [GLM 원인] {detail}") + if _glm_debug_enabled(): + traceback.print_exc() + plan = self._fallback_plan_from_summary( + state_summary, + last_error=detail, + ) thinking = plan.get("thinking", "") if thinking: @@ -220,6 +283,14 @@ class AIPlanner: prompt_chars = len(user_message) system_chars = len(SYSTEM_PROMPT) max_tokens = 2000 + http_timeout = float(os.environ.get("GLM_HTTP_TIMEOUT_SECONDS", "120")) + + if _glm_debug_enabled(): + print( + f"[GLM][디버그] POST {GLM_API_URL} | " + f"timeout={http_timeout}s | payload_bytes={len(payload)} | " + f"model={GLM_MODEL}" + ) req = urllib.request.Request( GLM_API_URL, @@ -235,7 +306,7 @@ class AIPlanner: t_payload0 = time.perf_counter() # payload 직렬화 직후(대략)부터 타임라인 측정 _t0 = time.perf_counter() - with urllib.request.urlopen(req, timeout=90) as resp: + with urllib.request.urlopen(req, timeout=http_timeout) as resp: raw_text = resp.read().decode("utf-8") t_read_done = time.perf_counter() @@ -262,7 +333,14 @@ class AIPlanner: ) return content except urllib.error.HTTPError as e: - raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}") + body = "" + try: + body = e.read().decode("utf-8", errors="replace") + except Exception: + body = "" + raise ConnectionError( + f"GLM API HTTP {e.code}: {body[:1200]}" + ) from e def _parse_json(self, raw: str) -> dict: text = raw.strip() @@ -354,3 +432,114 @@ class AIPlanner: self.long_term_goal = goal self.feedback_log.clear() print(f"[AI] 새 목표: {goal}") + + def _fallback_plan_from_summary(self, state_summary: str, last_error: str = "") -> dict: + """ + GLM 실패 시에도 상태 요약(주변 패치·기억된 광맥 좌표)이 있으면 + 무한 explore 루프 대신 mine_resource / move 를 선택한다. + """ + pos = self._parse_player_position(state_summary) + if pos is None: + px = py = None + else: + px, py = pos + anchors = self._parse_ore_anchors(state_summary) + + ore_priority = {"iron-ore": 0, "copper-ore": 1, "coal": 2, "stone": 3} + best: tuple[str, int, int, float] | None = None + best_key: tuple | None = None + + for ore, ox, oy in anchors: + if px is not None and py is not None: + dist = ((ox - px) ** 2 + (oy - py) ** 2) ** 0.5 + else: + dist = 0.0 + key = (ore_priority.get(ore, 9), dist) + if best_key is None or key < best_key: + best_key = key + best = (ore, ox, oy, dist) + + if best is not None: + ore, ox, oy, dist = best + move_threshold = float(os.environ.get("GLM_FALLBACK_MOVE_THRESHOLD", "200")) + actions: list[dict] = [] + if px is not None and py is not None and dist > move_threshold: + actions.append({ + "action": "move", + "params": {"x": ox, "y": oy}, + "reason": f"GLM 폴백: 광맥까지 약 {dist:.0f}타일 — 먼저 이동", + }) + actions.append({ + "action": "mine_resource", + "params": {"ore": ore, "count": 35}, + "reason": "GLM 폴백: 상태에 표시된 인근 광맥 채굴", + }) + err_note = f" ({last_error})" if last_error else "" + return { + "thinking": ( + f"GLM API를 사용할 수 없어 상태 요약의 광맥({ore}, 앵커 {ox},{oy})으로 " + f"채굴을 시도합니다.{err_note}" + ), + "current_goal": f"{ore} 채굴 (휴리스틱)", + "actions": actions, + "after_this": "GLM 복구 시 정상 계획으로 복귀", + } + + dirs = [ + "east", "north", "south", "west", + "northeast", "northwest", "southeast", "southwest", + ] + self._fallback_explore_turn += 1 + direction = dirs[(self._fallback_explore_turn - 1) % len(dirs)] + err_note = f" ({last_error})" if last_error else "" + return { + "thinking": ( + f"GLM API 실패이며 상태 요약에서 광맥 좌표를 찾지 못해 " + f"{direction} 방향으로 탐색합니다.{err_note}" + ), + "current_goal": "주변 탐색 (휴리스틱)", + "actions": [ + { + "action": "explore", + "params": {"direction": direction, "max_steps": 200}, + "reason": "GLM 폴백: 광맥 정보 없음 — 탐색", + }, + ], + "after_this": "자원 발견 후 채굴", + } + + @staticmethod + def _parse_player_position(text: str) -> tuple[float, float] | None: + m = re.search( + r"-\s*위치:\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)", + text, + ) + if not m: + return None + return float(m.group(1)), float(m.group(2)) + + @staticmethod + def _parse_ore_anchors(text: str) -> list[tuple[str, int, int]]: + """주변 스캔 '앵커' 좌표와 기억된 광맥 (tile_x, tile_y) 파싱.""" + seen: set[tuple[str, int, int]] = set() + out: list[tuple[str, int, int]] = [] + + for m in re.finditer( + r"-\s*(iron-ore|copper-ore|coal|stone):\s*\d+타일\s*\(\s*앵커:\s*(-?\d+)\s*,\s*(-?\d+)\s*\)", + text, + ): + t = (m.group(1), int(m.group(2)), int(m.group(3))) + if t not in seen: + seen.add(t) + out.append(t) + + for m in re.finditer( + r"-\s*(iron-ore|copper-ore|coal|stone):\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)", + text, + ): + t = (m.group(1), int(m.group(2)), int(m.group(3))) + if t not in seen: + seen.add(t) + out.append(t) + + return out diff --git a/docs/plan.md b/docs/plan.md index c278db3..4ebfaa4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,3 +1,19 @@ +## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지) + +### 문제 +- `mine_resource` 내부에서 약 0.1초마다 `update_selected_entity` + `mining_state = { mining = true, ... }`를 RCON으로 재전송하고 있었음. +- 플레이어는 보통 우클릭을 **누른 채 유지**하는데, 스크립트는 매번 상태를 다시 써서 **뗐다 누르는(re-trigger)** 것에 가까운 동작이 됨. + +### 변경 +- 이동·근처 광물 확인 후, 채굴 루프 **진입 직전에 한 번만** `mining_state` 설정. +- 루프에서는 `sleep`과 인벤토리/채굴 진행률 **읽기만** 수행. 종료·목표 달성 시에만 `mining = false`. + +### 범위 +- `action_executor.py` — `mine_resource` +- `README.md` — 동작 설명 한 줄 보강 + +--- + ## 채굴 실패 시 제외 좌표 반복 버그 수정 계획 ### 문제 재현/관찰 @@ -202,7 +218,7 @@ ### 변경 목표 1. `AIPlanner.decide()`의 재시도 예외 범위를 `TimeoutError`, `ConnectionError`, `urllib.error.URLError`까지 확장 2. 해당 오류가 발생하면 동일한 “plan 응답 받기” 재시도로 복구 시도 -3. 3회 연속 실패 시에는 기존과 동일하게 `explore` 기본 행동으로 폴백 +3. 3회 연속 실패 시에는 상태 요약 기반 휴리스틱(광맥 있으면 채굴/이동, 없으면 explore 방향 순환)으로 폴백 ### 구현 범위 - `ai_planner.py` @@ -211,3 +227,38 @@ ### README 업데이트 - GLM read timeout/연결 오류 발생 시 재시도 동작을 `주의사항`에 추가 +--- + +## GLM 전부 실패 시 explore 무한 루프 완화 (상태 기반 폴백) + +### 문제 관찰 +- GLM API가 `TimeoutError`/`ConnectionError`로 연속 실패하면 폴백이 항상 `explore`(고정 east)만 선택됨 +- 이미 `주변 자원 패치`·`기억된 광맥`에 철/석탄 좌표가 있는데도 탐색만 반복되어 진행이 멈춤 + +### 변경 목표 +1. `state_reader.summarize_for_ai()` 텍스트에서 플레이어 위치·앵커/기억 광맥 좌표를 정규식으로 추출 +2. 광맥이 있으면 `mine_resource` 우선(거리가 크면 `move` 선행), 없을 때만 `explore` 방향 순환 +3. 기본 HTTP 타임아웃 90초→120초, 재시도 간격 소폭 완화 + +### 구현 범위 +- `ai_planner.py`: `_fallback_plan_from_summary`, `_parse_player_position`, `_parse_ore_anchors`, 환경변수 `GLM_HTTP_TIMEOUT_SECONDS`, `GLM_FALLBACK_MOVE_THRESHOLD` + +### README 업데이트 +- 폴백 동작 및 환경변수 설명 반영 + +--- + +## GLM 예외 상세 로그 + 연결 검사 스크립트 + +### 목표 +- `TimeoutError`/`ConnectionError` 한 줄만으로는 DNS·SSL·프록시·HTTP 본문 오류를 구분하기 어려움 +- `URLError.reason`·체인 예외·errno를 항상 출력하고, `GLM_DEBUG=1`에서 스택까지 확보 +- `scripts/glm_connection_check.py`로 최소 POST만 수행해 네트워크/API 키를 분리 진단 + +### 구현 +- `ai_planner.describe_glm_exception()`, `_glm_debug_enabled()` +- `scripts/glm_connection_check.py` + +### README +- `GLM API 연결 문제 디버깅` 절 추가 + diff --git a/scripts/glm_connection_check.py b/scripts/glm_connection_check.py new file mode 100644 index 0000000..6608fdf --- /dev/null +++ b/scripts/glm_connection_check.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +GLM API 연결만 검사 (main.py와 동일한 urllib + URL). +에이전트 실행 전/타임아웃 원인 조사용. + + ZAI_API_KEY=... python scripts/glm_connection_check.py + GLM_DEBUG=1 GLM_HTTP_TIMEOUT_SECONDS=180 python scripts/glm_connection_check.py +""" +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.error +import urllib.request + +# 프로젝트 루트를 path에 넣어 ai_planner 상수 재사용 +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from ai_planner import ( # noqa: E402 + GLM_API_URL, + GLM_MODEL, + describe_glm_exception, + _glm_debug_enabled, +) + + +def main() -> int: + key = os.environ.get("ZAI_API_KEY", "").strip() + if not key: + print("[오류] ZAI_API_KEY 가 비어 있습니다.") + return 2 + + timeout = float(os.environ.get("GLM_HTTP_TIMEOUT_SECONDS", "120")) + payload = json.dumps({ + "model": GLM_MODEL, + "messages": [ + {"role": "user", "content": "Reply with exactly: OK"}, + ], + "max_tokens": 16, + "temperature": 0, + }).encode("utf-8") + + req = urllib.request.Request( + GLM_API_URL, + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + }, + method="POST", + ) + + if _glm_debug_enabled(): + print(f"[디버그] POST {GLM_API_URL}") + print(f"[디버그] timeout={timeout}s payload_bytes={len(payload)} model={GLM_MODEL}") + + t0 = time.perf_counter() + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + dt = time.perf_counter() - t0 + except Exception as e: + dt = time.perf_counter() - t0 + print(f"[실패] {dt:.2f}s 경과 후 예외") + print(f" {describe_glm_exception(e)}") + return 1 + + dt = time.perf_counter() - t0 + print(f"[성공] HTTP 응답 수신 {dt:.2f}s, 본문 {len(raw)}자") + try: + data = json.loads(raw) + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + preview = (content or "").strip().replace("\n", " ")[:200] + print(f" assistant 미리보기: {preview!r}") + except Exception as e: + print(f"[경고] JSON 파싱 실패: {e}") + print(f" 원문 앞 400자: {raw[:400]!r}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())