dsh-eval-guard 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -13
- package/assets/commit0/instance.json +30 -0
- package/assets/commit0/workspace.zip +0 -0
- package/assets/featurebench/hidden/test_regression.py +690 -0
- package/assets/featurebench/instance.json +16 -0
- package/assets/featurebench/workspace.zip +0 -0
- package/assets/swe-bench-pro/hidden/test.patch +36 -0
- package/assets/swe-bench-pro/instance.json +10 -0
- package/assets/swe-bench-pro/workspace.zip +0 -0
- package/eval/README.md +18 -0
- package/eval/arena/arena/README.md +21 -0
- package/eval/arena/arena/bt.py +104 -0
- package/eval/arena/arena/clock.py +15 -0
- package/eval/arena/arena/core.py +265 -0
- package/eval/arena/arena/run.cmd +24 -0
- package/eval/arena/arena/run.sh +11 -0
- package/eval/arena/arena/schedule.py +79 -0
- package/eval/arena/arena/tournament.py +183 -0
- package/eval/arena/bomberman/bots/README.md +16 -0
- package/eval/arena/bomberman/bots/deepseek-v4.1-flash.cpp +447 -0
- package/eval/arena/bomberman/bots/doubao-seed-2.1.cpp +542 -0
- package/eval/arena/bomberman/bots/glm-5.3.cpp +406 -0
- package/eval/arena/bomberman/bots/gpt-6-astra.cpp +768 -0
- package/eval/arena/bomberman/bots/gpt-6-sol.cpp +444 -0
- package/eval/arena/bomberman/bots/kimi-k3.cpp +432 -0
- package/eval/arena/bomberman/bots/mimo-v2.6-pro.cpp +529 -0
- package/eval/arena/bomberman/bots/opus-5.5.cpp +548 -0
- package/eval/arena/bomberman/bots/qwen-3.8-max.cpp +836 -0
- package/eval/arena/bomberman/tournament/README.md +11 -0
- package/eval/arena/bomberman/tournament/bt.py +56 -0
- package/eval/arena/bomberman/tournament/evaluate.py +199 -0
- package/eval/arena/bomberman/tournament/run.py +79 -0
- package/eval/arena/bomberman/tournament/schedule-2to10.json.gz +0 -0
- package/eval/arena/bomberman/workspace/MANIFEST.json +13 -0
- package/eval/arena/bomberman/workspace/README.md +29 -0
- package/eval/arena/bomberman/workspace/RULES.md +100 -0
- package/eval/arena/bomberman/workspace/baseline.cpp +240 -0
- package/eval/arena/bomberman/workspace/clock.py +25 -0
- package/eval/arena/bomberman/workspace/engine/arena.py +198 -0
- package/eval/arena/bomberman/workspace/engine/build.py +35 -0
- package/eval/arena/bomberman/workspace/engine/engine.py +256 -0
- package/eval/arena/bomberman/workspace/engine/verify_replay.py +24 -0
- package/eval/arena/bomberman/workspace/run.cmd +28 -0
- package/eval/arena/bomberman/workspace/run.sh +16 -0
- package/eval/arena/bomberman/workspace/viewer/index.html +42 -0
- package/eval/arena/ctf/bots/README.md +18 -0
- package/eval/arena/ctf/bots/deepseek-v4.1-flash.cpp +601 -0
- package/eval/arena/ctf/bots/doubao-seed-2.1-turbo.cpp +320 -0
- package/eval/arena/ctf/bots/fable-5.1.cpp +634 -0
- package/eval/arena/ctf/bots/glm-5.3.cpp +689 -0
- package/eval/arena/ctf/bots/gpt-6-astra.cpp +657 -0
- package/eval/arena/ctf/bots/gpt-6.1-sol.cpp +725 -0
- package/eval/arena/ctf/bots/kimi-k3.cpp +667 -0
- package/eval/arena/ctf/bots/mimo-v2.6-pro.cpp +533 -0
- package/eval/arena/ctf/bots/opus-5.5.cpp +645 -0
- package/eval/arena/ctf/bots/qwen-3.8-max.cpp +726 -0
- package/eval/arena/ctf/bots/sonnet-5.5.cpp +569 -0
- package/eval/arena/ctf/tournament/README.md +15 -0
- package/eval/arena/ctf/tournament/formal-2to8.json +31 -0
- package/eval/arena/ctf/tournament/formal-9to15.json +31 -0
- package/eval/arena/ctf/workspace/README.md +41 -0
- package/eval/arena/ctf/workspace/RULES.md +119 -0
- package/eval/arena/ctf/workspace/arena.py +186 -0
- package/eval/arena/ctf/workspace/baseline.cpp +257 -0
- package/eval/arena/ctf/workspace/clock.py +15 -0
- package/eval/arena/ctf/workspace/core.py +265 -0
- package/eval/arena/ctf/workspace/engine.py +389 -0
- package/eval/arena/ctf/workspace/run.cmd +24 -0
- package/eval/arena/ctf/workspace/run.sh +11 -0
- package/eval/arena/ctf/workspace/viewer.html +71 -0
- package/eval/arena/land/bots/README.md +17 -0
- package/eval/arena/land/bots/deepseek-4.1f@workbuddy#1/bot.cpp +608 -0
- package/eval/arena/land/bots/glm-5.3@workbuddy#1/bot.cpp +521 -0
- package/eval/arena/land/bots/gpt-6-astra@codex#1/bot.cpp +373 -0
- package/eval/arena/land/bots/gpt-6-sol@codex#1/bot.cpp +341 -0
- package/eval/arena/land/bots/kimi-k3@workbuddy#1/bot.cpp +492 -0
- package/eval/arena/land/bots/mimo-2.6-pro@zcode#1/bot.cpp +652 -0
- package/eval/arena/land/bots/mystery-model@zcode#1/bot.cpp +466 -0
- package/eval/arena/land/bots/opus-5.5@claude-code#1/bot.cpp +630 -0
- package/eval/arena/land/bots/sonnet-5@claude-code#1/bot.cpp +619 -0
- package/eval/arena/land/tournament/make_replay_page.py +17 -0
- package/eval/arena/land/tournament/rate.py +290 -0
- package/eval/arena/land/tournament/round_robin.py +172 -0
- package/eval/arena/land/tournament/sparring.cpp +356 -0
- package/eval/arena/land/workspace/RULES.md +114 -0
- package/eval/arena/land/workspace/TASK.md +46 -0
- package/eval/arena/land/workspace/anchors/.build/greedy_f2e6d2dea5ca +0 -0
- package/eval/arena/land/workspace/anchors/greedy.cpp +194 -0
- package/eval/arena/land/workspace/anchors/random.cpp +80 -0
- package/eval/arena/land/workspace/clock.py +64 -0
- package/eval/arena/land/workspace/engine/build.py +33 -0
- package/eval/arena/land/workspace/engine/game.py +273 -0
- package/eval/arena/land/workspace/engine/referee.py +308 -0
- package/eval/arena/land/workspace/starter/.build/bot_be32b45b4699 +0 -0
- package/eval/arena/land/workspace/starter/bot.cpp +82 -0
- package/eval/arena/land/workspace/viewer/viewer.html +210 -0
- package/eval/arena/tank/bots/README.md +24 -0
- package/eval/arena/tank/bots/half1/deepseek-v4.1-flash.cpp +367 -0
- package/eval/arena/tank/bots/half1/doubao-seed-evolving.cpp +402 -0
- package/eval/arena/tank/bots/half1/gemini-4-argon.cpp +1132 -0
- package/eval/arena/tank/bots/half1/glm-5.3.cpp +551 -0
- package/eval/arena/tank/bots/half1/gpt-6-astra.cpp +233 -0
- package/eval/arena/tank/bots/half1/gpt-6.1-sol-ultrafast.cpp +350 -0
- package/eval/arena/tank/bots/half1/gpt-6.1-sol.cpp +78 -0
- package/eval/arena/tank/bots/half1/grok-4.7.cpp +1099 -0
- package/eval/arena/tank/bots/half1/haiku-5.5.cpp +210 -0
- package/eval/arena/tank/bots/half1/kimi-k3.cpp +353 -0
- package/eval/arena/tank/bots/half1/mimo-v2.6-pro.cpp +336 -0
- package/eval/arena/tank/bots/half1/muse-wonder-full.cpp +560 -0
- package/eval/arena/tank/bots/half1/opus-5.5.cpp +435 -0
- package/eval/arena/tank/bots/half1/qwen-3.8-max.cpp +537 -0
- package/eval/arena/tank/bots/half2/deepseek-v4.1-flash.cpp +425 -0
- package/eval/arena/tank/bots/half2/doubao-seed-evolving.cpp +308 -0
- package/eval/arena/tank/bots/half2/gemini-4-argon.cpp +1390 -0
- package/eval/arena/tank/bots/half2/glm-5.3.cpp +651 -0
- package/eval/arena/tank/bots/half2/gpt-6-astra.cpp +241 -0
- package/eval/arena/tank/bots/half2/gpt-6.1-sol-ultrafast.cpp +356 -0
- package/eval/arena/tank/bots/half2/gpt-6.1-sol.cpp +87 -0
- package/eval/arena/tank/bots/half2/grok-4.7.cpp +1205 -0
- package/eval/arena/tank/bots/half2/haiku-5.5.cpp +210 -0
- package/eval/arena/tank/bots/half2/kimi-k3.cpp +487 -0
- package/eval/arena/tank/bots/half2/mimo-v2.6-pro.cpp +336 -0
- package/eval/arena/tank/bots/half2/muse-wonder-full.cpp +560 -0
- package/eval/arena/tank/bots/half2/opus-5.5.cpp +476 -0
- package/eval/arena/tank/bots/half2/qwen-3.8-max.cpp +392 -0
- package/eval/arena/tank/tournament/README.md +24 -0
- package/eval/arena/tank/tournament/bt.py +218 -0
- package/eval/arena/tank/tournament/final.json +202 -0
- package/eval/arena/tank/tournament/halftime.json +146 -0
- package/eval/arena/tank/tournament/progress.json +911 -0
- package/eval/arena/tank/tournament/schedule.py +100 -0
- package/eval/arena/tank/tournament/sparring.cpp +928 -0
- package/eval/arena/tank/tournament/tournament.py +1426 -0
- package/eval/arena/tank/workspace/README.md +169 -0
- package/eval/arena/tank/workspace/RULES.md +443 -0
- package/eval/arena/tank/workspace/arena.py +862 -0
- package/eval/arena/tank/workspace/baseline.cpp +259 -0
- package/eval/arena/tank/workspace/clock.py +103 -0
- package/eval/arena/tank/workspace/core.py +712 -0
- package/eval/arena/tank/workspace/engine.py +573 -0
- package/eval/arena/tank/workspace/history/.lock +0 -0
- package/eval/arena/tank/workspace/history/20261017-193610-08715287f91f-bot.cpp +109 -0
- package/eval/arena/tank/workspace/history/20261017-193619-f00b2d5635fa-gpt-6-astra.cpp +241 -0
- package/eval/arena/tank/workspace/history/20261017-193643-63319e0a2b1a-gpt-6_1-sol-ultrafast.cpp +356 -0
- package/eval/arena/tank/workspace/history/20261017-193647-41c1341d0a8a-gemini-4-argon.cpp +1390 -0
- package/eval/arena/tank/workspace/history/20261017-193652-e34c0f45539a-gpt-6_1-sol.cpp +87 -0
- package/eval/arena/tank/workspace/history/20261017-193654-c026348ce96e-opus-5_5.cpp +476 -0
- package/eval/arena/tank/workspace/history/20261017-193655-4a21366e94d1-muse-wonder-full.cpp +560 -0
- package/eval/arena/tank/workspace/history/20261017-193657-dbc4e9969c52-mimo-v2_6-pro.cpp +336 -0
- package/eval/arena/tank/workspace/history/20261017-193703-723cd036eb31-grok-4_7.cpp +1205 -0
- package/eval/arena/tank/workspace/history/20261017-193707-1ebbb227be68-deepseek-v4_1-flash.cpp +425 -0
- package/eval/arena/tank/workspace/history/20261017-193709-aeb0e7202ad5-doubao-seed-evolving.cpp +308 -0
- package/eval/arena/tank/workspace/history/20261017-193710-b21ebeb9bc76-kimi-k3.cpp +487 -0
- package/eval/arena/tank/workspace/history/20261017-193712-54cd86531905-haiku-5_5.cpp +210 -0
- package/eval/arena/tank/workspace/history/20261017-193714-efdb8e35c0bf-qwen-3_8-max.cpp +392 -0
- package/eval/arena/tank/workspace/history/20261017-193715-681423c23fdb-glm-5_3.cpp +651 -0
- package/eval/arena/tank/workspace/history/20261017-193717-1a8b3c75181d-sparring.cpp +928 -0
- package/eval/arena/tank/workspace/history/20261017-193720-1e0803a541e8-baseline.cpp +259 -0
- package/eval/arena/tank/workspace/history/log.jsonl +103 -0
- package/eval/arena/tank/workspace/judge/cycles.hpp +53 -0
- package/eval/arena/tank/workspace/judge/fast.cpp +337 -0
- package/eval/arena/tank/workspace/judge/referee.cpp +125 -0
- package/eval/arena/tank/workspace/judge/speed.cpp +99 -0
- package/eval/arena/tank/workspace/run.cmd +31 -0
- package/eval/arena/tank/workspace/run.sh +23 -0
- package/eval/arena/tank/workspace/tank.hpp +1762 -0
- package/eval/arena/tank/workspace/tank_main.cpp +215 -0
- package/eval/arena/tank/workspace/viewer.html +1150 -0
- package/eval/arena/warehouse/workspace/SPEC.md +130 -0
- package/eval/arena/warehouse/workspace/TASK.md +71 -0
- package/eval/arena/warehouse/workspace/baseline/common.h +88 -0
- package/eval/arena/warehouse/workspace/baseline/main.cpp +27 -0
- package/eval/arena/warehouse/workspace/baseline/old_stuff.cpp +87 -0
- package/eval/arena/warehouse/workspace/baseline/sim.cpp +427 -0
- package/eval/arena/warehouse/workspace/baseline/util.cpp +220 -0
- package/eval/arena/warehouse/workspace/clock.py +42 -0
- package/eval/arena/warehouse/workspace/src/common.h +88 -0
- package/eval/arena/warehouse/workspace/src/main.cpp +27 -0
- package/eval/arena/warehouse/workspace/src/old_stuff.cpp +87 -0
- package/eval/arena/warehouse/workspace/src/sim.cpp +427 -0
- package/eval/arena/warehouse/workspace/src/util.cpp +220 -0
- package/eval/arena/warehouse/workspace/tools/bench.py +119 -0
- package/eval/arena/warehouse/workspace/tools/check_input.py +161 -0
- package/eval/arena/warehouse/workspace/tools/gen.py +132 -0
- package/eval/arena/warehouse/workspace/tools/measure.py +107 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.in +1420 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.out +9162 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.in +1465 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.out +3439 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_flood.in +2757 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_flood.out +3126 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_small.in +731 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_small.out +2578 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_wide.in +528 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_wide.out +2026 -0
- package/eval/bomberman.py +20 -0
- package/eval/commit0.py +21 -0
- package/eval/common.py +61 -0
- package/eval/ctf.py +20 -0
- package/eval/featurebench.py +25 -0
- package/eval/land.py +20 -0
- package/eval/legacy/bomberman-prices.json +4 -0
- package/eval/legacy/bomberman.py +161 -0
- package/eval/legacy/commit0.py +91 -0
- package/eval/legacy/ctf-prices.json +4 -0
- package/eval/legacy/ctf.py +178 -0
- package/eval/legacy/featurebench.py +103 -0
- package/eval/legacy/land-prices.json +4 -0
- package/eval/legacy/land.py +177 -0
- package/eval/legacy/swe_bench_pro.py +116 -0
- package/eval/legacy/tank-prices.json +3 -0
- package/eval/legacy/tank.py +408 -0
- package/eval/legacy/warehouse-prices.json +4 -0
- package/eval/legacy/warehouse.py +365 -0
- package/eval/swe-bench-pro.py +25 -0
- package/eval/tank.py +20 -0
- package/eval/warehouse.py +20 -0
- package/lib/client/index.js +376 -63
- package/lib/client/index.js.map +1 -1
- package/lib/index.js +684 -27
- package/package.json +2 -1
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""对战平台核心(坦克大战版):编译 bot、启动 bot 进程、逐回合收发并计量思考预算、运行单局、读写回放。
|
|
2
|
+
|
|
3
|
+
这里不含任何游戏规则。游戏模块(engine.py)提供一个 Game 类:
|
|
4
|
+
Game(n, seed, **选项) n 个参赛方(坦克大战恒为 2),seed 决定地图
|
|
5
|
+
game.done、game.turn 是否结束、已结算的回合数
|
|
6
|
+
game.TIME_LIMITS 思考预算:(首回合秒数, 之后每回合秒数)。单位见下面"思考预算"一节
|
|
7
|
+
game.WALL_CAPS (可选)按 CPU 计时时的墙上保险上限:(首回合秒数, 之后每回合秒数)
|
|
8
|
+
game.encode(i) 发给第 i 方的本回合输入(文本,以换行结尾)
|
|
9
|
+
game.decode(i, line) 把第 i 方的一行回复解析成命令;格式不对时抛出 ValueError
|
|
10
|
+
game.step(orders) orders[i] 为第 i 方的命令(None 表示这一回合不动),返回 {"turn", "orders"}
|
|
11
|
+
game.forfeit(seats) 这些座位因故障判负,对局就此结束
|
|
12
|
+
game.header() 回放头部(引擎版本、种子、起始局面、预算)
|
|
13
|
+
game.result() 终局:score(每方得分)、winner、reason、turns 等
|
|
14
|
+
game.signature() 对局签名:两局的签名相同,就是逐回合完全相同的两局
|
|
15
|
+
game.close() 释放资源
|
|
16
|
+
|
|
17
|
+
bot 可以是 .cpp 源码(自动编译,按内容哈希缓存)或已经编译好的可执行文件。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import gzip
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import queue
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
MAX_STRIKES = 3 # 连续失败(超时、输出不合法)达到这个次数判负。与 judge/fast.cpp 里的同名常量一致
|
|
36
|
+
MAX_LINE = 4096 # bot 一行回复的长度上限
|
|
37
|
+
STDERR_MAX_LINES = 2000 # 每局每个 bot 的调试输出最多保留多少行
|
|
38
|
+
STDERR_MAX_CHARS = 200_000
|
|
39
|
+
STDERR_LINE_CHARS = 500
|
|
40
|
+
CXX_FLAGS = ["-std=c++20", "-O2"] + (["-static"] if os.name == "nt" else [])
|
|
41
|
+
EXE = ".exe" if os.name == "nt" else ""
|
|
42
|
+
DLL = ".dll" if os.name == "nt" else ".so"
|
|
43
|
+
|
|
44
|
+
# 选手包所在目录:tank.hpp、tank_main.cpp、judge/ 都在这里。默认是本文件所在目录。
|
|
45
|
+
KIT = Path(__file__).resolve().parent
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_kit(path):
|
|
49
|
+
"""本文件不在选手包目录里时(比如主办方从 平台/ 目录使用),先指明选手包目录。"""
|
|
50
|
+
global KIT
|
|
51
|
+
KIT = Path(path).resolve()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_build_dir():
|
|
55
|
+
return KIT / "build"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def setup_console():
|
|
59
|
+
"""让标准输出、标准错误按 utf-8 写,中文在 Windows 控制台和管道里都不乱码。"""
|
|
60
|
+
for stream in (sys.stdout, sys.stderr):
|
|
61
|
+
try:
|
|
62
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
63
|
+
except (AttributeError, ValueError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ====================================================================
|
|
68
|
+
# 思考预算:裁判侧的"缝"
|
|
69
|
+
# ====================================================================
|
|
70
|
+
# 预算的单位是"CPU 毫秒":bot 进程自己消耗的 CPU 量(所有线程合计),被挂起、等输入的时间不算。
|
|
71
|
+
# Windows QueryProcessCycleTime(进程句柄),周期数除以"每 CPU 毫秒多少周期"。这个系数由裁判核心测定
|
|
72
|
+
# (judge/cycles.hpp),engine.py 在开局时原样告诉 bot,两边用同一个数。
|
|
73
|
+
# Linux 进程的 CPU 时间时钟(按分析实现,未实测)。
|
|
74
|
+
# 其他系统,或环境变量 TANK_BUDGET=wall(调试用):退回墙上时钟,从发出局面到收到回复。
|
|
75
|
+
# 计费区间:上一回合收到回复 → 这一回合收到回复(首回合从进程启动算起),所以两回合之间偷着算的也计入。
|
|
76
|
+
# 等回复时每 POLL_SECONDS 读一次用量,超了当场判;另有墙上保险上限,只用来兜住睡眠、阻塞这类不耗 CPU 的情况。
|
|
77
|
+
# 预算和保险上限的数值由 tank.hpp 给出,engine.py 从裁判核心读来,这里不重复写。
|
|
78
|
+
#
|
|
79
|
+
# 换计时方式只改这里的 meter_read(裁判侧)和 tank.hpp 第 9 节的 detail::budget_reading(bot 侧)。
|
|
80
|
+
|
|
81
|
+
POLL_SECONDS = 0.005
|
|
82
|
+
UNITS_PER_MS = 1.0 # Windows:每 CPU 毫秒多少周期;由 configure_budget 设定
|
|
83
|
+
_k32 = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pick_budget_kind():
|
|
87
|
+
if os.environ.get("TANK_BUDGET", "").lower() == "wall":
|
|
88
|
+
return "wall"
|
|
89
|
+
if os.name == "nt":
|
|
90
|
+
return "cpu"
|
|
91
|
+
if sys.platform.startswith("linux") and hasattr(time, "clock_gettime"):
|
|
92
|
+
try:
|
|
93
|
+
time.clock_gettime(((~os.getpid()) << 3) | 2)
|
|
94
|
+
return "cpu"
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
print("(这个系统上没有可用的 CPU 计时,思考预算按墙上时钟计。)", file=sys.stderr)
|
|
98
|
+
return "wall"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
BUDGET_KIND = _pick_budget_kind() # "cpu" 或 "wall"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def configure_budget(units_per_ms):
|
|
105
|
+
"""设定"每 CPU 毫秒多少个读数单位"(Windows 上是周期数)。engine.py 从裁判核心读到后调用。"""
|
|
106
|
+
global UNITS_PER_MS
|
|
107
|
+
UNITS_PER_MS = float(units_per_ms)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _kernel32():
|
|
111
|
+
global _k32
|
|
112
|
+
if _k32 is None:
|
|
113
|
+
import ctypes
|
|
114
|
+
from ctypes import wintypes
|
|
115
|
+
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
116
|
+
k.QueryProcessCycleTime.argtypes = [wintypes.HANDLE, ctypes.POINTER(ctypes.c_uint64)]
|
|
117
|
+
k.QueryProcessCycleTime.restype = wintypes.BOOL
|
|
118
|
+
k.SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t]
|
|
119
|
+
k.SetProcessAffinityMask.restype = wintypes.BOOL
|
|
120
|
+
_k32 = k
|
|
121
|
+
return _k32
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def meter_read(proc):
|
|
125
|
+
"""【缝 · 裁判侧】某个 bot 进程此刻的读数(毫秒,只增不减)。一回合的用量 = 两次读数之差。
|
|
126
|
+
|
|
127
|
+
按 CPU 计时:进程从启动起消耗的 CPU 毫秒,所有线程合计;进程正在运行时也是实时的。
|
|
128
|
+
按墙上时钟:当前时刻。"""
|
|
129
|
+
if BUDGET_KIND == "cpu":
|
|
130
|
+
if os.name == "nt":
|
|
131
|
+
import ctypes
|
|
132
|
+
c = ctypes.c_uint64()
|
|
133
|
+
if not _kernel32().QueryProcessCycleTime(int(proc._handle), ctypes.byref(c)):
|
|
134
|
+
raise MatchError("读不到 bot 进程的 CPU 用量")
|
|
135
|
+
return c.value / UNITS_PER_MS
|
|
136
|
+
return time.clock_gettime(((~proc.pid) << 3) | 2) * 1000.0 # Linux:进程的 CPU 时间时钟(未实测)
|
|
137
|
+
return time.perf_counter() * 1000.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def set_affinity(proc, cpus):
|
|
141
|
+
"""把一个自己起的进程绑到给定的逻辑处理器上(编号列表)。空列表或 None 表示不绑。"""
|
|
142
|
+
if not cpus:
|
|
143
|
+
return
|
|
144
|
+
if os.name == "nt":
|
|
145
|
+
mask = 0
|
|
146
|
+
for c in cpus:
|
|
147
|
+
mask |= 1 << int(c)
|
|
148
|
+
if not _kernel32().SetProcessAffinityMask(int(proc._handle), mask):
|
|
149
|
+
raise MatchError(f"绑不到逻辑处理器 {list(cpus)}(只支持编号 0–63)")
|
|
150
|
+
elif hasattr(os, "sched_setaffinity"):
|
|
151
|
+
os.sched_setaffinity(proc.pid, set(int(c) for c in cpus)) # 未实测
|
|
152
|
+
else:
|
|
153
|
+
raise MatchError("这个系统上不支持绑定处理器")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cpu_topology():
|
|
157
|
+
"""本机的处理器拓扑(只读)。返回 [{"core": 物理核序号, "class": 效率等级, "cpus": [逻辑处理器编号]}]。
|
|
158
|
+
|
|
159
|
+
效率等级越大性能越高:大小核混合的处理器上,大核的等级比小核高;同构处理器上全是 0。
|
|
160
|
+
一个物理核有两个逻辑处理器就是开了超线程。只在 Windows 上实现,其他系统返回空列表。"""
|
|
161
|
+
if os.name != "nt":
|
|
162
|
+
return []
|
|
163
|
+
import ctypes
|
|
164
|
+
from ctypes import wintypes
|
|
165
|
+
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
166
|
+
k.GetLogicalProcessorInformationEx.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD)]
|
|
167
|
+
size = wintypes.DWORD(0)
|
|
168
|
+
k.GetLogicalProcessorInformationEx(0, None, ctypes.byref(size)) # 0 = RelationProcessorCore
|
|
169
|
+
buf = (ctypes.c_ubyte * size.value)()
|
|
170
|
+
if not k.GetLogicalProcessorInformationEx(0, buf, ctypes.byref(size)):
|
|
171
|
+
return []
|
|
172
|
+
raw, out, at = bytes(buf), [], 0
|
|
173
|
+
while at < size.value:
|
|
174
|
+
length = int.from_bytes(raw[at + 4:at + 8], "little")
|
|
175
|
+
# 记录头 8 字节;PROCESSOR_RELATIONSHIP:Flags(1) EfficiencyClass(1) Reserved(20) GroupCount(2),
|
|
176
|
+
# 接着每组 16 字节:Mask(8) Group(2) Reserved(6)。
|
|
177
|
+
eff = raw[at + 9]
|
|
178
|
+
groups = int.from_bytes(raw[at + 30:at + 32], "little")
|
|
179
|
+
cpus = []
|
|
180
|
+
for g in range(groups):
|
|
181
|
+
base = at + 32 + 16 * g
|
|
182
|
+
mask = int.from_bytes(raw[base:base + 8], "little")
|
|
183
|
+
group = int.from_bytes(raw[base + 8:base + 10], "little")
|
|
184
|
+
cpus += [64 * group + b for b in range(64) if mask >> b & 1]
|
|
185
|
+
out.append({"core": len(out), "class": eff, "cpus": cpus})
|
|
186
|
+
at += length
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ====================================================================
|
|
191
|
+
# 子进程管理:只管自己起的进程
|
|
192
|
+
# ====================================================================
|
|
193
|
+
# Windows 上把自己起的每个子进程放进一个"作业对象",本进程不论怎么结束(包括被强行结束),
|
|
194
|
+
# 系统都会把这些子进程一并结束。不按名字找进程,不碰别人的进程。
|
|
195
|
+
|
|
196
|
+
_job = None
|
|
197
|
+
_job_lock = threading.Lock()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _make_job():
|
|
201
|
+
import ctypes
|
|
202
|
+
from ctypes import wintypes
|
|
203
|
+
|
|
204
|
+
class BASIC(ctypes.Structure):
|
|
205
|
+
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), ("PerJobUserTimeLimit", ctypes.c_int64),
|
|
206
|
+
("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
207
|
+
("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
|
|
208
|
+
("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
|
|
209
|
+
("SchedulingClass", wintypes.DWORD)]
|
|
210
|
+
|
|
211
|
+
class IO(ctypes.Structure):
|
|
212
|
+
_fields_ = [(name, ctypes.c_uint64) for name in
|
|
213
|
+
("ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
|
214
|
+
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
|
|
215
|
+
|
|
216
|
+
class EXTENDED(ctypes.Structure):
|
|
217
|
+
_fields_ = [("BasicLimitInformation", BASIC), ("IoInfo", IO), ("ProcessMemoryLimit", ctypes.c_size_t),
|
|
218
|
+
("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
219
|
+
("PeakJobMemoryUsed", ctypes.c_size_t)]
|
|
220
|
+
|
|
221
|
+
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
222
|
+
k32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
223
|
+
k32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
224
|
+
k32.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
|
|
225
|
+
k32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
226
|
+
job = k32.CreateJobObjectW(None, None)
|
|
227
|
+
if not job:
|
|
228
|
+
return None
|
|
229
|
+
info = EXTENDED()
|
|
230
|
+
info.BasicLimitInformation.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
231
|
+
if not k32.SetInformationJobObject(job, 9, ctypes.byref(info), ctypes.sizeof(info)):
|
|
232
|
+
return None
|
|
233
|
+
# 子进程崩溃时不弹系统对话框(错误模式会被子进程继承)。
|
|
234
|
+
k32.SetErrorMode(0x0001 | 0x0002 | 0x8000)
|
|
235
|
+
return k32, job
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def adopt(proc):
|
|
239
|
+
"""登记一个自己起的子进程:本进程结束时它会被一并结束。失败不影响运行。"""
|
|
240
|
+
global _job
|
|
241
|
+
if os.name != "nt":
|
|
242
|
+
return
|
|
243
|
+
try:
|
|
244
|
+
with _job_lock:
|
|
245
|
+
if _job is None:
|
|
246
|
+
_job = _make_job() or False
|
|
247
|
+
if _job:
|
|
248
|
+
k32, job = _job
|
|
249
|
+
k32.AssignProcessToJobObject(job, int(proc._handle))
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def stop_process(proc):
|
|
255
|
+
"""结束一个自己起的子进程并回收。"""
|
|
256
|
+
if proc is None:
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
if proc.poll() is None:
|
|
260
|
+
proc.kill()
|
|
261
|
+
proc.wait(timeout=3)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
for pipe in (proc.stdin, proc.stdout, proc.stderr):
|
|
265
|
+
try:
|
|
266
|
+
if pipe:
|
|
267
|
+
pipe.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ====================================================================
|
|
273
|
+
# 编译
|
|
274
|
+
# ====================================================================
|
|
275
|
+
|
|
276
|
+
def find_compiler():
|
|
277
|
+
cxx = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
278
|
+
if cxx:
|
|
279
|
+
return cxx
|
|
280
|
+
if os.name == "nt":
|
|
281
|
+
here = Path(__file__).resolve()
|
|
282
|
+
for base in [*here.parents, Path.home() / "Desktop" / "测试"]:
|
|
283
|
+
guess = base / "00_项目管理" / "联调" / "toolchain" / "w64devkit" / "bin" / "g++.exe"
|
|
284
|
+
if guess.is_file():
|
|
285
|
+
return str(guess)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def sha_of(data):
|
|
290
|
+
return hashlib.sha256(data).hexdigest()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _key(*parts):
|
|
294
|
+
h = hashlib.sha256()
|
|
295
|
+
for p in parts:
|
|
296
|
+
b = p if isinstance(p, bytes) else str(p).encode("utf-8")
|
|
297
|
+
h.update(len(b).to_bytes(8, "little"))
|
|
298
|
+
h.update(b)
|
|
299
|
+
return h.hexdigest()[:16]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _compiler_id(cxx):
|
|
303
|
+
try:
|
|
304
|
+
st = os.stat(cxx)
|
|
305
|
+
return f"{cxx}|{st.st_size}|{int(st.st_mtime)}"
|
|
306
|
+
except OSError:
|
|
307
|
+
return str(cxx)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _ascii(path):
|
|
311
|
+
return all(ord(ch) < 128 for ch in str(path))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _run_cxx(args, out, shown=()):
|
|
315
|
+
"""运行一次编译器,产物先写临时文件再改名,所以并行编译同一个目标也不会互相踩坏。"""
|
|
316
|
+
cxx = find_compiler()
|
|
317
|
+
if not cxx:
|
|
318
|
+
raise RuntimeError("没有找到 C++ 编译器(g++ 或 clang++)")
|
|
319
|
+
out = Path(out)
|
|
320
|
+
if out.is_file():
|
|
321
|
+
return out
|
|
322
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
tmp = out.with_name(f"tmp-{os.getpid()}-{threading.get_ident()}-{out.name}")
|
|
324
|
+
env = dict(os.environ)
|
|
325
|
+
env["PATH"] = str(Path(cxx).resolve().parent) + os.pathsep + env.get("PATH", "")
|
|
326
|
+
try:
|
|
327
|
+
r = subprocess.run([cxx, *CXX_FLAGS, *[str(a) for a in args], "-o", str(tmp)], capture_output=True,
|
|
328
|
+
encoding="utf-8", errors="replace", timeout=300, env=env, stdin=subprocess.DEVNULL)
|
|
329
|
+
except subprocess.TimeoutExpired:
|
|
330
|
+
raise RuntimeError("编译超过 300 秒")
|
|
331
|
+
except OSError as exc:
|
|
332
|
+
raise RuntimeError(f"编译器无法运行:{exc}")
|
|
333
|
+
if r.returncode != 0:
|
|
334
|
+
try:
|
|
335
|
+
tmp.unlink()
|
|
336
|
+
except OSError:
|
|
337
|
+
pass
|
|
338
|
+
msg = (r.stderr or r.stdout or "")[-4000:]
|
|
339
|
+
for real, name in shown:
|
|
340
|
+
msg = msg.replace(str(real), str(name)).replace(str(real).replace("\\", "/"), str(name))
|
|
341
|
+
hint = ""
|
|
342
|
+
if not _ascii(out) or not all(_ascii(a) for a in args):
|
|
343
|
+
hint = "\n(路径里有非英文字符,这个编译器可能打不开。请把整个目录放到纯英文路径下再试。)"
|
|
344
|
+
raise RuntimeError("编译失败:\n" + msg.rstrip() + hint)
|
|
345
|
+
try:
|
|
346
|
+
os.replace(tmp, out)
|
|
347
|
+
except OSError:
|
|
348
|
+
if not out.is_file(): # 别的进程已经放好了同名文件(内容相同)就算成功
|
|
349
|
+
raise
|
|
350
|
+
try:
|
|
351
|
+
tmp.unlink()
|
|
352
|
+
except OSError:
|
|
353
|
+
pass
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _replace(tmp, path, same_content=False):
|
|
358
|
+
"""改名覆盖。Windows 上目标文件正被别的进程开着、或者另一个同时运行的命令正往同一个名字上改名时会被拒绝
|
|
359
|
+
(WinError 5):等一下再试,最多约 3 秒。same_content=True 表示同名的目标内容必然相同(文件名里带着内容的哈希),
|
|
360
|
+
别的进程已经放好了就算成功。"""
|
|
361
|
+
for attempt in range(60):
|
|
362
|
+
try:
|
|
363
|
+
os.replace(tmp, path)
|
|
364
|
+
return
|
|
365
|
+
except OSError:
|
|
366
|
+
if same_content and Path(path).is_file():
|
|
367
|
+
try:
|
|
368
|
+
Path(tmp).unlink()
|
|
369
|
+
except OSError:
|
|
370
|
+
pass
|
|
371
|
+
return
|
|
372
|
+
if attempt == 59:
|
|
373
|
+
raise
|
|
374
|
+
time.sleep(0.05)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _glue_object(kind, build):
|
|
378
|
+
"""主循环 tank_main.cpp 编成目标文件:kind 为 "exe"(正式模式)或 "dll"(快速模式)。"""
|
|
379
|
+
main, hpp = KIT / "tank_main.cpp", KIT / "tank.hpp"
|
|
380
|
+
key = _key(main.read_bytes(), hpp.read_bytes(), " ".join(CXX_FLAGS), kind, _compiler_id(find_compiler()), os.name)
|
|
381
|
+
flags = ["-c", "-I", KIT]
|
|
382
|
+
if kind == "dll":
|
|
383
|
+
flags += ["-DTANK_FAST_DLL"] + ([] if os.name == "nt" else ["-fPIC"])
|
|
384
|
+
return _run_cxx([*flags, main], build / f"tank_main-{kind}-{key}.o")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _bot_object(source, kind, build):
|
|
388
|
+
"""bot 源码编成目标文件。正式模式和快速模式用同一个目标文件(Windows 上),所以两边是同一份机器码。"""
|
|
389
|
+
source = Path(source)
|
|
390
|
+
data = source.read_bytes()
|
|
391
|
+
pic = kind == "dll" and os.name != "nt"
|
|
392
|
+
key = _key(data, (KIT / "tank.hpp").read_bytes(), " ".join(CXX_FLAGS), _compiler_id(find_compiler()), os.name, pic)
|
|
393
|
+
obj = build / f"bot-{key}.o"
|
|
394
|
+
if obj.is_file():
|
|
395
|
+
return obj, key
|
|
396
|
+
# 把源码抄一份到编译目录再编:文件名固定是英文的,编译途中源文件被改也不影响。
|
|
397
|
+
build.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
copy = build / f"bot-{key}.cpp"
|
|
399
|
+
if not copy.is_file():
|
|
400
|
+
tmp = copy.with_name(f"tmp-{os.getpid()}-{threading.get_ident()}-{copy.name}")
|
|
401
|
+
tmp.write_bytes(data)
|
|
402
|
+
_replace(tmp, copy, same_content=True)
|
|
403
|
+
flags = ["-c", "-I", KIT, "-I", source.resolve().parent] + (["-fPIC"] if pic else [])
|
|
404
|
+
_run_cxx([*flags, copy], obj, shown=[(copy, source), (copy.name, source.name)])
|
|
405
|
+
return obj, key
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def build_bot(source, kind="exe", build_dir=None):
|
|
409
|
+
"""把一份 bot 源码编成可执行文件(kind="exe",正式模式)或动态库(kind="dll",快速模式)。
|
|
410
|
+
|
|
411
|
+
按源码内容、tank.hpp、主循环、编译参数的哈希缓存,都没变就不重编。
|
|
412
|
+
产物的文件名不含源文件名(bot 能从命令行看到自己的文件名)。失败时抛出 RuntimeError,附编译器的输出。"""
|
|
413
|
+
build = Path(build_dir) if build_dir else default_build_dir()
|
|
414
|
+
obj, key = _bot_object(source, kind, build)
|
|
415
|
+
glue = _glue_object(kind, build)
|
|
416
|
+
out = build / f"bot-{_key(key, glue.name)}{EXE if kind == 'exe' else DLL}"
|
|
417
|
+
if out.is_file():
|
|
418
|
+
return out
|
|
419
|
+
return _run_cxx(([] if kind == "exe" else ["-shared"]) + [obj, glue], out)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def build_tool(name, build_dir=None):
|
|
423
|
+
"""编译 judge/ 下的工具(referee、fast),按内容哈希缓存。"""
|
|
424
|
+
build = Path(build_dir) if build_dir else default_build_dir()
|
|
425
|
+
source = KIT / "judge" / f"{name}.cpp"
|
|
426
|
+
key = _key(source.read_bytes(), (KIT / "tank.hpp").read_bytes(), " ".join(CXX_FLAGS),
|
|
427
|
+
_compiler_id(find_compiler()), os.name)
|
|
428
|
+
libs = [] if os.name == "nt" else ["-ldl", "-pthread"]
|
|
429
|
+
return _run_cxx([source, *libs], build / f"{name}-{key}{EXE}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def is_source(path):
|
|
433
|
+
return Path(path).suffix.lower() in (".cpp", ".cc", ".cxx")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def command_for(program, build_dir=None):
|
|
437
|
+
"""返回正式模式下启动这个 bot 的命令。源码先编译;其余当作可执行文件。"""
|
|
438
|
+
p = Path(program)
|
|
439
|
+
if is_source(p):
|
|
440
|
+
return [str(build_bot(p, "exe", build_dir))]
|
|
441
|
+
return [str(p.resolve())]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ====================================================================
|
|
445
|
+
# bot 进程
|
|
446
|
+
# ====================================================================
|
|
447
|
+
|
|
448
|
+
EXIT_CODES = {0xC0000005: "非法内存访问", 0xC00000FD: "栈溢出", 0xC0000094: "除以零", 0xC000001D: "非法指令",
|
|
449
|
+
0xC0000409: "栈缓冲区溢出或 abort", 3: "abort 或未接住的异常"}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def describe_exit(code):
|
|
453
|
+
if code is None:
|
|
454
|
+
return "进程已退出"
|
|
455
|
+
code &= 0xFFFFFFFF
|
|
456
|
+
note = EXIT_CODES.get(code)
|
|
457
|
+
text = f"0x{code:08X}" if code > 255 else str(code)
|
|
458
|
+
return f"进程已退出(代码 {text}{',' + note if note else ''})"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class MatchError(RuntimeError):
|
|
462
|
+
"""裁判这一侧的问题(程序起不来、裁判核心出错等),不算在任何选手头上。"""
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Bot:
|
|
466
|
+
"""一个 bot 进程。每局起一个新的,工作目录是一个全新的临时目录。"""
|
|
467
|
+
|
|
468
|
+
def __init__(self, command, limits_ms, caps_ms=None, affinity=None):
|
|
469
|
+
self.first_limit, self.turn_limit = limits_ms # 预算(毫秒):首回合、之后每回合
|
|
470
|
+
self.first_cap, self.turn_cap = caps_ms or limits_ms # 墙上保险上限(只在按 CPU 计时时起作用)
|
|
471
|
+
self.charged = 0.0 # 已经计过费的读数:上一回合收到回复(或判超时)那一刻的
|
|
472
|
+
self.strikes = 0
|
|
473
|
+
self.dead = None # 判负原因
|
|
474
|
+
self.faults = [] # [{"turn", "kind", "detail"}]
|
|
475
|
+
self.calls = 0
|
|
476
|
+
self.turn = 0 # 正在等哪一回合的回复(给调试输出打回合标记用)
|
|
477
|
+
self.used = [] # 每回合的预算用量(毫秒)
|
|
478
|
+
self.stderr = [] # [[回合, 一行]]
|
|
479
|
+
self.stderr_dropped = 0
|
|
480
|
+
self._stderr_chars = 0
|
|
481
|
+
self.lines = queue.Queue()
|
|
482
|
+
self.proc = None
|
|
483
|
+
self.dir = Path(tempfile.mkdtemp(prefix="tank_"))
|
|
484
|
+
try:
|
|
485
|
+
self.proc = subprocess.Popen(command, cwd=self.dir, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
486
|
+
stderr=subprocess.PIPE)
|
|
487
|
+
except OSError as exc:
|
|
488
|
+
shutil.rmtree(self.dir, ignore_errors=True)
|
|
489
|
+
raise MatchError(f"程序无法启动:{command[0]}({exc})")
|
|
490
|
+
adopt(self.proc)
|
|
491
|
+
try:
|
|
492
|
+
set_affinity(self.proc, affinity)
|
|
493
|
+
except Exception:
|
|
494
|
+
stop_process(self.proc)
|
|
495
|
+
shutil.rmtree(self.dir, ignore_errors=True)
|
|
496
|
+
raise
|
|
497
|
+
self._threads = [threading.Thread(target=self._read_stdout, daemon=True),
|
|
498
|
+
threading.Thread(target=self._read_stderr, daemon=True)]
|
|
499
|
+
for t in self._threads:
|
|
500
|
+
t.start()
|
|
501
|
+
|
|
502
|
+
def _read_stdout(self):
|
|
503
|
+
try:
|
|
504
|
+
while True:
|
|
505
|
+
raw = self.proc.stdout.readline(MAX_LINE + 1)
|
|
506
|
+
if not raw:
|
|
507
|
+
break
|
|
508
|
+
self.lines.put((meter_read(self.proc), raw))
|
|
509
|
+
except (OSError, ValueError):
|
|
510
|
+
pass
|
|
511
|
+
self.lines.put((meter_read(self.proc), None))
|
|
512
|
+
|
|
513
|
+
def _read_stderr(self):
|
|
514
|
+
try:
|
|
515
|
+
while True:
|
|
516
|
+
raw = self.proc.stderr.readline(65536)
|
|
517
|
+
if not raw:
|
|
518
|
+
break
|
|
519
|
+
if len(self.stderr) >= STDERR_MAX_LINES or self._stderr_chars >= STDERR_MAX_CHARS:
|
|
520
|
+
self.stderr_dropped += 1
|
|
521
|
+
continue
|
|
522
|
+
text = raw.decode("utf-8", errors="replace").rstrip("\r\n")[:STDERR_LINE_CHARS]
|
|
523
|
+
self._stderr_chars += len(text)
|
|
524
|
+
self.stderr.append([self.turn, text])
|
|
525
|
+
except (OSError, ValueError):
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
def send(self, text, turn):
|
|
529
|
+
"""写入本回合的输入。返回 (计费起点的读数, 发出时的墙上时刻);bot 已判负时返回 None。"""
|
|
530
|
+
if self.dead:
|
|
531
|
+
return None
|
|
532
|
+
self.turn = turn
|
|
533
|
+
try:
|
|
534
|
+
self.proc.stdin.write(text.encode("utf-8"))
|
|
535
|
+
self.proc.stdin.flush()
|
|
536
|
+
except (OSError, ValueError):
|
|
537
|
+
pass # 进程多半已经退出;交给 receive 从输出那一头确认,原因更准确
|
|
538
|
+
# 按 CPU 计时:从上一回合收到回复时的读数算起(首回合从 0,即进程启动算起)。
|
|
539
|
+
# 按墙上时钟:从此刻算起。
|
|
540
|
+
start = self.charged if BUDGET_KIND == "cpu" else meter_read(self.proc)
|
|
541
|
+
return start, time.perf_counter()
|
|
542
|
+
|
|
543
|
+
def receive(self, started, turn):
|
|
544
|
+
"""在预算内读到本回合的一行回复就返回它;否则记一次失败并返回 None。"""
|
|
545
|
+
if self.dead or started is None:
|
|
546
|
+
self.used.append(None)
|
|
547
|
+
return None
|
|
548
|
+
reading0, wall0 = started
|
|
549
|
+
first = self.calls == 0
|
|
550
|
+
self.calls += 1
|
|
551
|
+
limit = self.first_limit if first else self.turn_limit
|
|
552
|
+
cpu = BUDGET_KIND == "cpu"
|
|
553
|
+
cap = (self.first_cap if first else self.turn_cap) if cpu else limit
|
|
554
|
+
deadline = wall0 + cap / 1000.0
|
|
555
|
+
|
|
556
|
+
def over(reading, detail):
|
|
557
|
+
self.charged = reading
|
|
558
|
+
self.used.append(round(reading - reading0, 3))
|
|
559
|
+
return self.strike(turn, "timeout", detail)
|
|
560
|
+
|
|
561
|
+
while True:
|
|
562
|
+
wait = max(0.0, deadline - time.perf_counter())
|
|
563
|
+
try:
|
|
564
|
+
reading, raw = self.lines.get(timeout=min(wait, POLL_SECONDS) if cpu else wait)
|
|
565
|
+
except queue.Empty:
|
|
566
|
+
now = meter_read(self.proc)
|
|
567
|
+
if cpu and now - reading0 > limit:
|
|
568
|
+
return over(now, f"超出预算({limit:g} CPU 毫秒)") # 死循环、算得太久在这里被发现
|
|
569
|
+
if time.perf_counter() >= deadline:
|
|
570
|
+
return over(now, f"超过墙上保险上限({cap:g} 毫秒没有回复)" if cpu else f"超出预算({limit:g} 毫秒)")
|
|
571
|
+
continue
|
|
572
|
+
if raw is None:
|
|
573
|
+
self.used.append(round(reading - reading0, 3))
|
|
574
|
+
code = None
|
|
575
|
+
try:
|
|
576
|
+
code = self.proc.wait(timeout=2)
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
self.kill(turn, "exit", describe_exit(code))
|
|
580
|
+
return None
|
|
581
|
+
line = raw.decode("utf-8", errors="replace")
|
|
582
|
+
head = line.split(None, 1)[0] if line.strip() else ""
|
|
583
|
+
if head.isdigit() and int(head) < turn:
|
|
584
|
+
continue # 以前某个超时回合迟到的回复,丢掉
|
|
585
|
+
if reading - reading0 > limit:
|
|
586
|
+
return over(reading, f"超出预算({limit:g} {'CPU ' if cpu else ''}毫秒)")
|
|
587
|
+
self.charged = reading
|
|
588
|
+
self.used.append(round(reading - reading0, 3))
|
|
589
|
+
if len(raw) > MAX_LINE:
|
|
590
|
+
return self.strike(turn, "illegal", f"一行输出超过 {MAX_LINE} 字节")
|
|
591
|
+
return line
|
|
592
|
+
|
|
593
|
+
def ok(self):
|
|
594
|
+
self.strikes = 0
|
|
595
|
+
|
|
596
|
+
def strike(self, turn, kind, detail):
|
|
597
|
+
self.strikes += 1
|
|
598
|
+
self.faults.append({"turn": turn, "kind": kind, "detail": detail})
|
|
599
|
+
if self.strikes >= MAX_STRIKES and not self.dead:
|
|
600
|
+
self.dead = f"连续 {MAX_STRIKES} 次失败,最后一次:{detail}"
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
def kill(self, turn, kind, detail):
|
|
604
|
+
if not self.dead:
|
|
605
|
+
self.dead = detail
|
|
606
|
+
self.faults.append({"turn": turn, "kind": kind, "detail": detail})
|
|
607
|
+
|
|
608
|
+
def close(self):
|
|
609
|
+
stop_process(self.proc)
|
|
610
|
+
for t in getattr(self, "_threads", []):
|
|
611
|
+
t.join(timeout=1)
|
|
612
|
+
for _ in range(5):
|
|
613
|
+
shutil.rmtree(self.dir, ignore_errors=True)
|
|
614
|
+
if not self.dir.exists():
|
|
615
|
+
break
|
|
616
|
+
time.sleep(0.05)
|
|
617
|
+
|
|
618
|
+
def report(self):
|
|
619
|
+
later = [u for u in self.used[1:] if u is not None]
|
|
620
|
+
return {"dead": self.dead, "faults": len(self.faults),
|
|
621
|
+
"timeouts": sum(f["kind"] == "timeout" for f in self.faults),
|
|
622
|
+
"illegal": sum(f["kind"] == "illegal" for f in self.faults),
|
|
623
|
+
"first_ms": self.used[0] if self.used and self.used[0] is not None else 0.0,
|
|
624
|
+
"avg_ms": round(sum(later) / len(later), 3) if later else 0.0,
|
|
625
|
+
"max_ms": max(later) if later else 0.0,
|
|
626
|
+
"stderr_tail": [text for _, text in self.stderr[-10:]]}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def run_match(game_cls, commands, names, seed, record=True, affinity=None, **options):
|
|
630
|
+
"""正式模式打一局。commands[i](由 command_for 得到)坐第 i 号位。返回 (结果, 回放或 None)。
|
|
631
|
+
|
|
632
|
+
affinity:可选,[0 号位的逻辑处理器编号列表, 1 号位的],把两个 bot 进程分别绑在这些处理器上;默认不绑。
|
|
633
|
+
|
|
634
|
+
bot 的超时、非法输出、崩溃记在它自己头上;裁判这一侧出的错抛出异常(MatchError 等),不产生结果。"""
|
|
635
|
+
game = game_cls(len(commands), seed, **options)
|
|
636
|
+
bots = []
|
|
637
|
+
orders_log, faults = [], []
|
|
638
|
+
try:
|
|
639
|
+
limits = tuple(1000.0 * x for x in game.TIME_LIMITS)
|
|
640
|
+
caps = tuple(1000.0 * x for x in getattr(game, "WALL_CAPS", None) or game.TIME_LIMITS)
|
|
641
|
+
for i, c in enumerate(commands):
|
|
642
|
+
bots.append(Bot(c, limits, caps, affinity[i] if affinity else None))
|
|
643
|
+
while not game.done:
|
|
644
|
+
turn = game.turn + 1
|
|
645
|
+
texts = [game.encode(i) for i in range(len(bots))]
|
|
646
|
+
starts = [b.send(texts[i], turn) for i, b in enumerate(bots)]
|
|
647
|
+
orders = [None] * len(bots)
|
|
648
|
+
for i, b in enumerate(bots):
|
|
649
|
+
line = b.receive(starts[i], turn)
|
|
650
|
+
if line is None:
|
|
651
|
+
continue
|
|
652
|
+
try:
|
|
653
|
+
orders[i] = game.decode(i, line)
|
|
654
|
+
b.ok()
|
|
655
|
+
except ValueError as exc:
|
|
656
|
+
b.strike(turn, "illegal", f"输出不合法:{exc}(收到:{line.strip()[:80]})")
|
|
657
|
+
out = [i for i, b in enumerate(bots) if b.dead]
|
|
658
|
+
if out:
|
|
659
|
+
game.forfeit(out) # 判负的那一回合不结算
|
|
660
|
+
break
|
|
661
|
+
orders_log.append(game.step(orders)["orders"])
|
|
662
|
+
result = game.result()
|
|
663
|
+
header = game.header()
|
|
664
|
+
signature = game.signature()
|
|
665
|
+
finally:
|
|
666
|
+
for b in bots:
|
|
667
|
+
b.close()
|
|
668
|
+
game.close()
|
|
669
|
+
for i, b in enumerate(bots):
|
|
670
|
+
faults += [{"seat": i, **f} for f in b.faults]
|
|
671
|
+
faults.sort(key=lambda f: (f["turn"], f["seat"]))
|
|
672
|
+
result.update(seed=seed, names=list(names), bots=[b.report() for b in bots], sig=signature,
|
|
673
|
+
n_faults=len(faults))
|
|
674
|
+
if not record:
|
|
675
|
+
return result, None
|
|
676
|
+
n = max(len(b.used) for b in bots)
|
|
677
|
+
used = [[(b.used[t] if t < len(b.used) else None) for b in bots] for t in range(n)]
|
|
678
|
+
replay = {**header, "mode": "formal", "names": list(names), "orders": orders_log, "used_ms": used,
|
|
679
|
+
"faults": faults, "affinity": affinity, "stderr": [b.stderr for b in bots],
|
|
680
|
+
"stderr_dropped": [b.stderr_dropped for b in bots], "result": result}
|
|
681
|
+
return result, replay
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ====================================================================
|
|
685
|
+
# 回放文件
|
|
686
|
+
# ====================================================================
|
|
687
|
+
|
|
688
|
+
def save_replay(replay, path):
|
|
689
|
+
"""保存回放(JSON,utf-8)。文件名以 .gz 结尾时用 gzip 压缩。返回实际路径。"""
|
|
690
|
+
path = Path(path)
|
|
691
|
+
if path.suffix not in (".json", ".gz"):
|
|
692
|
+
path = path.with_name(path.name + ".json")
|
|
693
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
694
|
+
raw = json.dumps(replay, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
695
|
+
if path.suffix == ".gz":
|
|
696
|
+
raw = gzip.compress(raw)
|
|
697
|
+
tmp = path.with_name(f"tmp-{os.getpid()}-{path.name}")
|
|
698
|
+
tmp.write_bytes(raw)
|
|
699
|
+
_replace(tmp, path)
|
|
700
|
+
return path
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def load_replay(path):
|
|
704
|
+
raw = Path(path).read_bytes()
|
|
705
|
+
if raw[:2] == b"\x1f\x8b":
|
|
706
|
+
raw = gzip.decompress(raw)
|
|
707
|
+
return json.loads(raw.decode("utf-8"))
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def safe_name(text, fallback="x"):
|
|
711
|
+
"""把任意文字变成只含英文字母、数字、下划线、短横线的文件名片段。"""
|
|
712
|
+
return re.sub(r"[^A-Za-z0-9_-]+", "_", str(text)).strip("_")[:40] or fallback
|