dsh-eval-guard 0.2.2 → 0.3.0

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.
Files changed (224) hide show
  1. package/README.md +91 -13
  2. package/assets/bomberman/workspace.zip +0 -0
  3. package/assets/commit0/instance.json +30 -0
  4. package/assets/commit0/workspace.zip +0 -0
  5. package/assets/ctf/workspace.zip +0 -0
  6. package/assets/featurebench/hidden/test_regression.py +690 -0
  7. package/assets/featurebench/instance.json +16 -0
  8. package/assets/featurebench/workspace.zip +0 -0
  9. package/assets/swe-bench-pro/hidden/test.patch +36 -0
  10. package/assets/swe-bench-pro/instance.json +10 -0
  11. package/assets/swe-bench-pro/workspace.zip +0 -0
  12. package/assets/tank/workspace.zip +0 -0
  13. package/eval/README.md +18 -0
  14. package/eval/arena/arena/README.md +21 -0
  15. package/eval/arena/arena/bt.py +104 -0
  16. package/eval/arena/arena/clock.py +15 -0
  17. package/eval/arena/arena/core.py +265 -0
  18. package/eval/arena/arena/run.cmd +24 -0
  19. package/eval/arena/arena/run.sh +11 -0
  20. package/eval/arena/arena/schedule.py +79 -0
  21. package/eval/arena/arena/tournament.py +183 -0
  22. package/eval/arena/bomberman/bots/README.md +16 -0
  23. package/eval/arena/bomberman/bots/deepseek-v4.1-flash.cpp +447 -0
  24. package/eval/arena/bomberman/bots/doubao-seed-2.1.cpp +542 -0
  25. package/eval/arena/bomberman/bots/glm-5.3.cpp +406 -0
  26. package/eval/arena/bomberman/bots/gpt-6-astra.cpp +768 -0
  27. package/eval/arena/bomberman/bots/gpt-6-sol.cpp +444 -0
  28. package/eval/arena/bomberman/bots/kimi-k3.cpp +432 -0
  29. package/eval/arena/bomberman/bots/mimo-v2.6-pro.cpp +529 -0
  30. package/eval/arena/bomberman/bots/opus-5.5.cpp +548 -0
  31. package/eval/arena/bomberman/bots/qwen-3.8-max.cpp +836 -0
  32. package/eval/arena/bomberman/tournament/README.md +11 -0
  33. package/eval/arena/bomberman/tournament/bt.py +56 -0
  34. package/eval/arena/bomberman/tournament/evaluate.py +199 -0
  35. package/eval/arena/bomberman/tournament/run.py +79 -0
  36. package/eval/arena/bomberman/tournament/schedule-2to10.json.gz +0 -0
  37. package/eval/arena/bomberman/workspace/MANIFEST.json +13 -0
  38. package/eval/arena/bomberman/workspace/README.md +29 -0
  39. package/eval/arena/bomberman/workspace/RULES.md +100 -0
  40. package/eval/arena/bomberman/workspace/baseline.cpp +240 -0
  41. package/eval/arena/bomberman/workspace/clock.py +25 -0
  42. package/eval/arena/bomberman/workspace/engine/arena.py +198 -0
  43. package/eval/arena/bomberman/workspace/engine/build.py +35 -0
  44. package/eval/arena/bomberman/workspace/engine/engine.py +256 -0
  45. package/eval/arena/bomberman/workspace/engine/verify_replay.py +24 -0
  46. package/eval/arena/bomberman/workspace/run.cmd +28 -0
  47. package/eval/arena/bomberman/workspace/run.sh +16 -0
  48. package/eval/arena/bomberman/workspace/viewer/index.html +42 -0
  49. package/eval/arena/ctf/bots/README.md +18 -0
  50. package/eval/arena/ctf/bots/deepseek-v4.1-flash.cpp +601 -0
  51. package/eval/arena/ctf/bots/doubao-seed-2.1-turbo.cpp +320 -0
  52. package/eval/arena/ctf/bots/fable-5.1.cpp +634 -0
  53. package/eval/arena/ctf/bots/glm-5.3.cpp +689 -0
  54. package/eval/arena/ctf/bots/gpt-6-astra.cpp +657 -0
  55. package/eval/arena/ctf/bots/gpt-6.1-sol.cpp +725 -0
  56. package/eval/arena/ctf/bots/kimi-k3.cpp +667 -0
  57. package/eval/arena/ctf/bots/mimo-v2.6-pro.cpp +533 -0
  58. package/eval/arena/ctf/bots/opus-5.5.cpp +645 -0
  59. package/eval/arena/ctf/bots/qwen-3.8-max.cpp +726 -0
  60. package/eval/arena/ctf/bots/sonnet-5.5.cpp +569 -0
  61. package/eval/arena/ctf/tournament/README.md +15 -0
  62. package/eval/arena/ctf/tournament/formal-2to8.json +31 -0
  63. package/eval/arena/ctf/tournament/formal-9to15.json +31 -0
  64. package/eval/arena/ctf/workspace/README.md +41 -0
  65. package/eval/arena/ctf/workspace/RULES.md +119 -0
  66. package/eval/arena/ctf/workspace/arena.py +186 -0
  67. package/eval/arena/ctf/workspace/baseline.cpp +257 -0
  68. package/eval/arena/ctf/workspace/clock.py +15 -0
  69. package/eval/arena/ctf/workspace/core.py +265 -0
  70. package/eval/arena/ctf/workspace/engine.py +389 -0
  71. package/eval/arena/ctf/workspace/run.cmd +24 -0
  72. package/eval/arena/ctf/workspace/run.sh +11 -0
  73. package/eval/arena/ctf/workspace/viewer.html +71 -0
  74. package/eval/arena/land/bots/README.md +17 -0
  75. package/eval/arena/land/bots/deepseek-4.1f@workbuddy#1/bot.cpp +608 -0
  76. package/eval/arena/land/bots/glm-5.3@workbuddy#1/bot.cpp +521 -0
  77. package/eval/arena/land/bots/gpt-6-astra@codex#1/bot.cpp +373 -0
  78. package/eval/arena/land/bots/gpt-6-sol@codex#1/bot.cpp +341 -0
  79. package/eval/arena/land/bots/kimi-k3@workbuddy#1/bot.cpp +492 -0
  80. package/eval/arena/land/bots/mimo-2.6-pro@zcode#1/bot.cpp +652 -0
  81. package/eval/arena/land/bots/mystery-model@zcode#1/bot.cpp +466 -0
  82. package/eval/arena/land/bots/opus-5.5@claude-code#1/bot.cpp +630 -0
  83. package/eval/arena/land/bots/sonnet-5@claude-code#1/bot.cpp +619 -0
  84. package/eval/arena/land/tournament/make_replay_page.py +17 -0
  85. package/eval/arena/land/tournament/rate.py +290 -0
  86. package/eval/arena/land/tournament/round_robin.py +172 -0
  87. package/eval/arena/land/tournament/sparring.cpp +356 -0
  88. package/eval/arena/land/workspace/RULES.md +114 -0
  89. package/eval/arena/land/workspace/TASK.md +46 -0
  90. package/eval/arena/land/workspace/anchors/.build/greedy_f2e6d2dea5ca +0 -0
  91. package/eval/arena/land/workspace/anchors/greedy.cpp +194 -0
  92. package/eval/arena/land/workspace/anchors/random.cpp +80 -0
  93. package/eval/arena/land/workspace/clock.py +64 -0
  94. package/eval/arena/land/workspace/engine/build.py +33 -0
  95. package/eval/arena/land/workspace/engine/game.py +273 -0
  96. package/eval/arena/land/workspace/engine/referee.py +308 -0
  97. package/eval/arena/land/workspace/starter/.build/bot_be32b45b4699 +0 -0
  98. package/eval/arena/land/workspace/starter/bot.cpp +82 -0
  99. package/eval/arena/land/workspace/viewer/viewer.html +210 -0
  100. package/eval/arena/tank/bots/README.md +24 -0
  101. package/eval/arena/tank/bots/half1/deepseek-v4.1-flash.cpp +367 -0
  102. package/eval/arena/tank/bots/half1/doubao-seed-evolving.cpp +402 -0
  103. package/eval/arena/tank/bots/half1/gemini-4-argon.cpp +1132 -0
  104. package/eval/arena/tank/bots/half1/glm-5.3.cpp +551 -0
  105. package/eval/arena/tank/bots/half1/gpt-6-astra.cpp +233 -0
  106. package/eval/arena/tank/bots/half1/gpt-6.1-sol-ultrafast.cpp +350 -0
  107. package/eval/arena/tank/bots/half1/gpt-6.1-sol.cpp +78 -0
  108. package/eval/arena/tank/bots/half1/grok-4.7.cpp +1099 -0
  109. package/eval/arena/tank/bots/half1/haiku-5.5.cpp +210 -0
  110. package/eval/arena/tank/bots/half1/kimi-k3.cpp +353 -0
  111. package/eval/arena/tank/bots/half1/mimo-v2.6-pro.cpp +336 -0
  112. package/eval/arena/tank/bots/half1/muse-wonder-full.cpp +560 -0
  113. package/eval/arena/tank/bots/half1/opus-5.5.cpp +435 -0
  114. package/eval/arena/tank/bots/half1/qwen-3.8-max.cpp +537 -0
  115. package/eval/arena/tank/bots/half2/deepseek-v4.1-flash.cpp +425 -0
  116. package/eval/arena/tank/bots/half2/doubao-seed-evolving.cpp +308 -0
  117. package/eval/arena/tank/bots/half2/gemini-4-argon.cpp +1390 -0
  118. package/eval/arena/tank/bots/half2/glm-5.3.cpp +651 -0
  119. package/eval/arena/tank/bots/half2/gpt-6-astra.cpp +241 -0
  120. package/eval/arena/tank/bots/half2/gpt-6.1-sol-ultrafast.cpp +356 -0
  121. package/eval/arena/tank/bots/half2/gpt-6.1-sol.cpp +87 -0
  122. package/eval/arena/tank/bots/half2/grok-4.7.cpp +1205 -0
  123. package/eval/arena/tank/bots/half2/haiku-5.5.cpp +210 -0
  124. package/eval/arena/tank/bots/half2/kimi-k3.cpp +487 -0
  125. package/eval/arena/tank/bots/half2/mimo-v2.6-pro.cpp +336 -0
  126. package/eval/arena/tank/bots/half2/muse-wonder-full.cpp +560 -0
  127. package/eval/arena/tank/bots/half2/opus-5.5.cpp +476 -0
  128. package/eval/arena/tank/bots/half2/qwen-3.8-max.cpp +392 -0
  129. package/eval/arena/tank/tournament/README.md +24 -0
  130. package/eval/arena/tank/tournament/bt.py +218 -0
  131. package/eval/arena/tank/tournament/final.json +202 -0
  132. package/eval/arena/tank/tournament/halftime.json +146 -0
  133. package/eval/arena/tank/tournament/progress.json +911 -0
  134. package/eval/arena/tank/tournament/schedule.py +100 -0
  135. package/eval/arena/tank/tournament/sparring.cpp +928 -0
  136. package/eval/arena/tank/tournament/tournament.py +1426 -0
  137. package/eval/arena/tank/workspace/README.md +169 -0
  138. package/eval/arena/tank/workspace/RULES.md +443 -0
  139. package/eval/arena/tank/workspace/arena.py +862 -0
  140. package/eval/arena/tank/workspace/baseline.cpp +259 -0
  141. package/eval/arena/tank/workspace/clock.py +103 -0
  142. package/eval/arena/tank/workspace/core.py +712 -0
  143. package/eval/arena/tank/workspace/engine.py +573 -0
  144. package/eval/arena/tank/workspace/history/.lock +0 -0
  145. package/eval/arena/tank/workspace/history/20261017-193610-08715287f91f-bot.cpp +109 -0
  146. package/eval/arena/tank/workspace/history/20261017-193619-f00b2d5635fa-gpt-6-astra.cpp +241 -0
  147. package/eval/arena/tank/workspace/history/20261017-193643-63319e0a2b1a-gpt-6_1-sol-ultrafast.cpp +356 -0
  148. package/eval/arena/tank/workspace/history/20261017-193647-41c1341d0a8a-gemini-4-argon.cpp +1390 -0
  149. package/eval/arena/tank/workspace/history/20261017-193652-e34c0f45539a-gpt-6_1-sol.cpp +87 -0
  150. package/eval/arena/tank/workspace/history/20261017-193654-c026348ce96e-opus-5_5.cpp +476 -0
  151. package/eval/arena/tank/workspace/history/20261017-193655-4a21366e94d1-muse-wonder-full.cpp +560 -0
  152. package/eval/arena/tank/workspace/history/20261017-193657-dbc4e9969c52-mimo-v2_6-pro.cpp +336 -0
  153. package/eval/arena/tank/workspace/history/20261017-193703-723cd036eb31-grok-4_7.cpp +1205 -0
  154. package/eval/arena/tank/workspace/history/20261017-193707-1ebbb227be68-deepseek-v4_1-flash.cpp +425 -0
  155. package/eval/arena/tank/workspace/history/20261017-193709-aeb0e7202ad5-doubao-seed-evolving.cpp +308 -0
  156. package/eval/arena/tank/workspace/history/20261017-193710-b21ebeb9bc76-kimi-k3.cpp +487 -0
  157. package/eval/arena/tank/workspace/history/20261017-193712-54cd86531905-haiku-5_5.cpp +210 -0
  158. package/eval/arena/tank/workspace/history/20261017-193714-efdb8e35c0bf-qwen-3_8-max.cpp +392 -0
  159. package/eval/arena/tank/workspace/history/20261017-193715-681423c23fdb-glm-5_3.cpp +651 -0
  160. package/eval/arena/tank/workspace/history/20261017-193717-1a8b3c75181d-sparring.cpp +928 -0
  161. package/eval/arena/tank/workspace/history/20261017-193720-1e0803a541e8-baseline.cpp +259 -0
  162. package/eval/arena/tank/workspace/history/log.jsonl +103 -0
  163. package/eval/arena/tank/workspace/judge/cycles.hpp +53 -0
  164. package/eval/arena/tank/workspace/judge/fast.cpp +337 -0
  165. package/eval/arena/tank/workspace/judge/referee.cpp +125 -0
  166. package/eval/arena/tank/workspace/judge/speed.cpp +99 -0
  167. package/eval/arena/tank/workspace/run.cmd +31 -0
  168. package/eval/arena/tank/workspace/run.sh +23 -0
  169. package/eval/arena/tank/workspace/tank.hpp +1762 -0
  170. package/eval/arena/tank/workspace/tank_main.cpp +215 -0
  171. package/eval/arena/tank/workspace/viewer.html +1150 -0
  172. package/eval/arena/warehouse/workspace/SPEC.md +130 -0
  173. package/eval/arena/warehouse/workspace/TASK.md +71 -0
  174. package/eval/arena/warehouse/workspace/baseline/common.h +88 -0
  175. package/eval/arena/warehouse/workspace/baseline/main.cpp +27 -0
  176. package/eval/arena/warehouse/workspace/baseline/old_stuff.cpp +87 -0
  177. package/eval/arena/warehouse/workspace/baseline/sim.cpp +427 -0
  178. package/eval/arena/warehouse/workspace/baseline/util.cpp +220 -0
  179. package/eval/arena/warehouse/workspace/clock.py +42 -0
  180. package/eval/arena/warehouse/workspace/src/common.h +88 -0
  181. package/eval/arena/warehouse/workspace/src/main.cpp +27 -0
  182. package/eval/arena/warehouse/workspace/src/old_stuff.cpp +87 -0
  183. package/eval/arena/warehouse/workspace/src/sim.cpp +427 -0
  184. package/eval/arena/warehouse/workspace/src/util.cpp +220 -0
  185. package/eval/arena/warehouse/workspace/tools/bench.py +119 -0
  186. package/eval/arena/warehouse/workspace/tools/check_input.py +161 -0
  187. package/eval/arena/warehouse/workspace/tools/gen.py +132 -0
  188. package/eval/arena/warehouse/workspace/tools/measure.py +107 -0
  189. package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.in +1420 -0
  190. package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.out +9162 -0
  191. package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.in +1465 -0
  192. package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.out +3439 -0
  193. package/eval/arena/warehouse/workspace/workloads/public/pub_flood.in +2757 -0
  194. package/eval/arena/warehouse/workspace/workloads/public/pub_flood.out +3126 -0
  195. package/eval/arena/warehouse/workspace/workloads/public/pub_small.in +731 -0
  196. package/eval/arena/warehouse/workspace/workloads/public/pub_small.out +2578 -0
  197. package/eval/arena/warehouse/workspace/workloads/public/pub_wide.in +528 -0
  198. package/eval/arena/warehouse/workspace/workloads/public/pub_wide.out +2026 -0
  199. package/eval/bomberman.py +20 -0
  200. package/eval/commit0.py +21 -0
  201. package/eval/common.py +61 -0
  202. package/eval/ctf.py +20 -0
  203. package/eval/featurebench.py +25 -0
  204. package/eval/land.py +20 -0
  205. package/eval/legacy/bomberman-prices.json +4 -0
  206. package/eval/legacy/bomberman.py +161 -0
  207. package/eval/legacy/commit0.py +91 -0
  208. package/eval/legacy/ctf-prices.json +4 -0
  209. package/eval/legacy/ctf.py +178 -0
  210. package/eval/legacy/featurebench.py +103 -0
  211. package/eval/legacy/land-prices.json +4 -0
  212. package/eval/legacy/land.py +177 -0
  213. package/eval/legacy/swe_bench_pro.py +116 -0
  214. package/eval/legacy/tank-prices.json +3 -0
  215. package/eval/legacy/tank.py +408 -0
  216. package/eval/legacy/warehouse-prices.json +4 -0
  217. package/eval/legacy/warehouse.py +365 -0
  218. package/eval/swe-bench-pro.py +25 -0
  219. package/eval/tank.py +20 -0
  220. package/eval/warehouse.py +20 -0
  221. package/lib/client/index.js +376 -63
  222. package/lib/client/index.js.map +1 -1
  223. package/lib/index.js +684 -27
  224. package/package.json +2 -1
@@ -0,0 +1,265 @@
1
+ """对战平台核心:编译并启动选手程序,逐回合同时收发,运行单局并保存回放。
2
+
3
+ 所有游戏共用。游戏模块需要提供一个 Game 类:
4
+ Game(n, seed) n 个参赛方,seed 决定地图
5
+ game.done、game.turn 是否结束、已结算的回合数
6
+ game.encode(i) 发给第 i 方的本回合输入(文本,以换行结尾)
7
+ game.decode(i, line) 把第 i 方的一行回复解析成指令(dict);格式不对时抛出 ValueError
8
+ game.step(orders) orders[i] 为第 i 方的指令(缺席为 {}),返回本回合帧(dict)
9
+ game.header() 回放头部:地图等静态信息(dict)
10
+ game.frame() (可选)当前局面,用作回放的第 0 帧
11
+ game.result() 终局:至少含 "score"(每方得分,越高越好)
12
+ Game.TIME_LIMITS (可选)(首回合秒数, 之后每回合秒数),默认 (2.0, 0.05)
13
+
14
+ 选手程序可以是 .cpp(自动编译)、可执行文件或 .py。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import os
20
+ import queue
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import threading
26
+ import time
27
+ from collections import deque
28
+ from pathlib import Path
29
+
30
+ DEFAULT_LIMITS = (2.0, 0.05)
31
+ MAX_STRIKES = 3 # 连续失败次数达到此值即判故障出局
32
+ MAX_LINE = 65536
33
+ CXX_FLAGS = ["-std=c++20", "-O2"] + (["-static"] if os.name == "nt" else [])
34
+
35
+
36
+ def find_compiler():
37
+ cxx = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
38
+ if cxx:
39
+ return cxx
40
+ if os.name == "nt": # 没加进 PATH 的 w64devkit:仓库根目录下的 tools/,或 C 盘根目录
41
+ here = Path(__file__).resolve()
42
+ for base in [*(p / "tools" for p in here.parents), Path("C:/")]:
43
+ guess = base / "w64devkit" / "bin" / "g++.exe"
44
+ if guess.is_file():
45
+ return str(guess)
46
+ return None
47
+
48
+
49
+ def compile_cpp(source, build_dir):
50
+ """编译 C++ 源码,按内容哈希缓存。返回可执行文件路径;失败时抛出 RuntimeError 并附编译器输出。"""
51
+ source = Path(source)
52
+ cxx = find_compiler()
53
+ if not cxx:
54
+ raise RuntimeError("没有找到 C++ 编译器(g++ 或 clang++)")
55
+ digest = hashlib.sha256(source.read_bytes() + " ".join(CXX_FLAGS).encode()).hexdigest()[:12]
56
+ build_dir = Path(build_dir)
57
+ build_dir.mkdir(parents=True, exist_ok=True)
58
+ exe = build_dir / f"{source.stem}-{digest}{'.exe' if os.name == 'nt' else ''}"
59
+ if exe.is_file():
60
+ return exe
61
+ tmp = exe.with_name(exe.stem + ".tmp" + exe.suffix)
62
+ try:
63
+ r = subprocess.run([cxx, *CXX_FLAGS, str(source), "-o", str(tmp)], capture_output=True, text=True,
64
+ encoding="utf-8", errors="replace", timeout=120)
65
+ except subprocess.TimeoutExpired:
66
+ raise RuntimeError("编译超过 120 秒")
67
+ if r.returncode != 0:
68
+ raise RuntimeError("编译失败:\n" + (r.stderr or r.stdout)[-3000:])
69
+ os.replace(tmp, exe)
70
+ return exe
71
+
72
+
73
+ def command_for(program, build_dir):
74
+ p = Path(program)
75
+ if p.suffix.lower() in (".cpp", ".cc", ".cxx"):
76
+ return [str(compile_cpp(p, build_dir))]
77
+ if p.suffix.lower() in (".py", ".pyc"):
78
+ return [sys.executable, "-X", "utf8", str(p)]
79
+ return [str(p)]
80
+
81
+
82
+ class Bot:
83
+ """一个选手进程。每局以一个全新的临时目录作为工作目录。"""
84
+
85
+ def __init__(self, command, name, limits):
86
+ self.name = name
87
+ self.first_limit, self.turn_limit = limits
88
+ self.strikes = 0
89
+ self.dead = None # 出局原因
90
+ self.faults = [] # [(回合, 原因)]
91
+ self.calls = 0
92
+ self.stale = 0 # 已超时、尚未读到的迟到回复数,读到后丢弃
93
+ self.stderr = deque(maxlen=20)
94
+ self.first_seconds = 0.0 # 第一回合(含启动)单独记录
95
+ self.total_seconds = 0.0
96
+ self.max_seconds = 0.0
97
+ self.lines = queue.Queue()
98
+ self.dir = Path(tempfile.mkdtemp(prefix="arena_"))
99
+ try:
100
+ self.proc = subprocess.Popen(command, cwd=self.dir, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
101
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
102
+ bufsize=1)
103
+ except OSError as exc:
104
+ self.proc = None
105
+ self.dead = f"无法启动:{exc}"
106
+ return
107
+ threading.Thread(target=self._read_stdout, daemon=True).start()
108
+ threading.Thread(target=self._read_stderr, daemon=True).start()
109
+
110
+ def _read_stdout(self):
111
+ try:
112
+ while True:
113
+ line = self.proc.stdout.readline(MAX_LINE + 1)
114
+ if not line:
115
+ break
116
+ self.lines.put((time.perf_counter(), line))
117
+ except (OSError, ValueError):
118
+ pass
119
+ self.lines.put((time.perf_counter(), None))
120
+
121
+ def _read_stderr(self):
122
+ try:
123
+ while True:
124
+ line = self.proc.stderr.readline(4096)
125
+ if not line:
126
+ break
127
+ self.stderr.append(line.rstrip())
128
+ except (OSError, ValueError):
129
+ pass
130
+
131
+ def send(self, text):
132
+ """写入本回合输入,返回开始计时的时刻。"""
133
+ if self.dead:
134
+ return None
135
+ try:
136
+ self.proc.stdin.write(text)
137
+ self.proc.stdin.flush()
138
+ except (OSError, ValueError):
139
+ self._kill(f"进程已退出(代码 {self.proc.poll()})")
140
+ return None
141
+ return time.perf_counter()
142
+
143
+ def receive(self, started, turn):
144
+ """在时限内读取一行回复,返回这一行;失败时返回 None 并记一次失败。"""
145
+ if self.dead or started is None:
146
+ return None
147
+ limit = self.first_limit if self.calls == 0 else self.turn_limit
148
+ self.calls += 1
149
+ deadline = started + limit
150
+ while True:
151
+ try:
152
+ arrived, line = self.lines.get(timeout=max(0.0, deadline - time.perf_counter()))
153
+ except queue.Empty:
154
+ self.stale += 1
155
+ return self.strike(turn, f"超时(>{limit * 1000:g} 毫秒)")
156
+ if line is None:
157
+ self._kill(f"进程已退出(代码 {self.proc.poll()})", turn)
158
+ return None
159
+ if self.stale:
160
+ self.stale -= 1
161
+ continue
162
+ if arrived > deadline:
163
+ return self.strike(turn, f"超时(>{limit * 1000:g} 毫秒)")
164
+ break
165
+ spent = arrived - started
166
+ if self.calls == 1:
167
+ self.first_seconds = spent
168
+ else:
169
+ self.total_seconds += spent
170
+ self.max_seconds = max(self.max_seconds, spent)
171
+ if len(line) > MAX_LINE:
172
+ return self.strike(turn, "输出一行超过 64 KiB")
173
+ return line
174
+
175
+ def ok(self):
176
+ self.strikes = 0
177
+
178
+ def strike(self, turn, reason):
179
+ self.strikes += 1
180
+ self.faults.append((turn, reason))
181
+ if self.strikes >= MAX_STRIKES:
182
+ self._kill(f"连续 {MAX_STRIKES} 次失败,最后一次:{reason}", turn)
183
+ return None
184
+
185
+ def _kill(self, reason, turn=None):
186
+ if not self.dead:
187
+ self.dead = reason
188
+ if turn is not None:
189
+ self.faults.append((turn, reason))
190
+ self.close()
191
+
192
+ def close(self):
193
+ p = self.proc
194
+ if p is not None and p.poll() is None:
195
+ p.kill()
196
+ try:
197
+ p.wait(timeout=2)
198
+ except subprocess.TimeoutExpired:
199
+ pass
200
+ if p is not None:
201
+ for pipe in (p.stdin, p.stdout, p.stderr):
202
+ try:
203
+ pipe.close()
204
+ except (OSError, ValueError):
205
+ pass
206
+ shutil.rmtree(self.dir, ignore_errors=True)
207
+
208
+ def report(self):
209
+ return {"name": self.name, "dead": self.dead, "faults": self.faults,
210
+ "first_ms": round(1000 * self.first_seconds, 1),
211
+ "avg_ms": round(1000 * self.total_seconds / max(1, self.calls - 1), 2),
212
+ "max_ms": round(1000 * self.max_seconds, 2),
213
+ "stderr_tail": list(self.stderr)[-5:]}
214
+
215
+
216
+ def run_match(game_cls, commands, names, seed, record=True):
217
+ """运行一局。commands[i](由 command_for 得到)坐第 i 号位。返回 (结果, 回放或 None)。"""
218
+ game = game_cls(len(commands), seed)
219
+ limits = getattr(game_cls, "TIME_LIMITS", DEFAULT_LIMITS)
220
+ bots = [Bot(c, n, limits) for c, n in zip(commands, names)]
221
+ frames = [game.frame()] if record and hasattr(game, "frame") else []
222
+ try:
223
+ while not game.done:
224
+ turn = game.turn + 1
225
+ starts = [b.send(game.encode(i)) for i, b in enumerate(bots)]
226
+ orders = {}
227
+ for i, (b, s) in enumerate(zip(bots, starts)):
228
+ line = b.receive(s, turn)
229
+ if line is None:
230
+ orders[i] = {}
231
+ continue
232
+ try:
233
+ orders[i] = game.decode(i, line)
234
+ b.ok()
235
+ except ValueError as exc:
236
+ orders[i] = {}
237
+ b.strike(turn, f"输出格式错误:{exc}")
238
+ frame = game.step(orders)
239
+ for i, b in enumerate(bots):
240
+ for t, reason in b.faults:
241
+ if t == turn:
242
+ frame.setdefault("faults", []).append({"team": i, "reason": reason})
243
+ if record:
244
+ frames.append(frame)
245
+ finally:
246
+ for b in bots:
247
+ b.close()
248
+ result = game.result()
249
+ result.update(seed=seed, names=list(names), bots=[b.report() for b in bots])
250
+ if not record:
251
+ return result, None
252
+ replay = {"header": game.header(), "names": list(names), "result": result, "frames": frames}
253
+ return result, replay
254
+
255
+
256
+ def save_replay(replay, path, viewer=None):
257
+ """保存回放 JSON;给出 viewer 模板时,另存一份可双击打开的 HTML。"""
258
+ import json
259
+ path = Path(path)
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ raw = json.dumps(replay, ensure_ascii=False, separators=(",", ":"))
262
+ path.with_suffix(".json").write_text(raw, encoding="utf-8")
263
+ if viewer:
264
+ html = Path(viewer).read_text(encoding="utf-8").replace("/*__REPLAY__*/null", raw.replace("</", "<\\/"))
265
+ path.with_suffix(".html").write_text(html, encoding="utf-8")
@@ -0,0 +1,389 @@
1
+ """多阵营夺旗 v1.0 规则引擎。只用标准库,结算完全确定。"""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import math
6
+ import random
7
+ import secrets
8
+ from collections import Counter
9
+
10
+ VERSION = "flag-1.0"
11
+ MOVES = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0), "WAIT": (0, 0)}
12
+ RULES = {"turns": 400, "hp": 100, "damage": 34, "attack_range": 2,
13
+ "respawn": 10, "flag_return": 15, "flag_cooldown": 8}
14
+ TEXT_MOVES = {"U": "N", "D": "S", "L": "W", "R": "E", "S": "WAIT"}
15
+
16
+
17
+ def distance(a, b):
18
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
19
+
20
+
21
+ def map_size(n):
22
+ return 23 if n == 2 else 31 if n <= 5 else 41 if n <= 10 else 49
23
+
24
+
25
+ def base_centers(n, c, r):
26
+ """基地中心放在以地图中心为圆心、曼哈顿半径 r 的菱形上,避开四个顶点
27
+ (这样每个基地到中心的距离分布完全相同),并让相邻基地的间距尽量相等。"""
28
+ allowed = []
29
+ for side in range(4):
30
+ for s in range(2, r - 1):
31
+ allowed.append(((c - r + s, c - s), (c + s, c - r + s), (c + r - s, c + s), (c - s, c + r - s))[side])
32
+ m = len(allowed)
33
+
34
+ def gaps(idx):
35
+ pts = [allowed[i] for i in idx]
36
+ return [distance(pts[i], pts[(i + 1) % n]) for i in range(n)]
37
+
38
+ def cost(idx):
39
+ g = gaps(idx)
40
+ return (max(g) - min(g), sum((x - sum(g) / n) ** 2 for x in g))
41
+
42
+ best = None
43
+ for off in range(m):
44
+ idx = [(off + round(i * m / n)) % m for i in range(n)]
45
+ while True:
46
+ improved = False
47
+ for i in range(n):
48
+ for d in (-1, 1):
49
+ trial = idx[:]
50
+ trial[i] = (trial[i] + d) % m
51
+ if len(set(trial)) == n and cost(trial) < cost(idx):
52
+ idx, improved = trial, True
53
+ if not improved:
54
+ break
55
+ if best is None or cost(idx) < cost(best):
56
+ best = idx
57
+ return [allowed[i] for i in best]
58
+
59
+
60
+ def spots_between(bases, center):
61
+ """每个基地前方的旗点:离基地中心正好 D 步(约 60% 路程)、离地图中心正好 r-D 步,
62
+ 横纵都严格落在两者之间,所以每个基地的出生点到自家旗点距离完全相同。"""
63
+ cx, cy = center
64
+ out = []
65
+ for x, y in bases:
66
+ ax, ay = abs(cx - x), abs(cy - y)
67
+ r = ax + ay
68
+ d = round(0.6 * r)
69
+ dx = min(max(round(d * ax / r), 1), d - 1, ax - 1)
70
+ dy = d - dx
71
+ sx, sy = (1 if cx > x else -1), (1 if cy > y else -1)
72
+ out.append((x + sx * dx, y + sy * dy))
73
+ return out
74
+
75
+
76
+ class Game:
77
+ TIME_LIMITS = (2.0, 0.05) # 首回合 2 秒,之后每回合 50 毫秒
78
+
79
+ def __init__(self, n, seed=1, flag_seed=None):
80
+ if not 2 <= n <= 15:
81
+ raise ValueError("支持 2–15 个阵营")
82
+ self.n, self.seed = n, seed
83
+ self.rng = random.Random(seed) # 只用来生成地图
84
+ # 旗子出现在哪个旗点用另一个随机数决定,不传时每局现取,不能从地图反推
85
+ self.flag_seed = secrets.randbits(64) if flag_seed is None else flag_seed
86
+ self.flag_rng = random.Random(self.flag_seed)
87
+ self.size = map_size(n)
88
+ c = self.size // 2
89
+ self.center = (c, c)
90
+ self.base_center = base_centers(n, c, c - 3)
91
+ self.bases = [[(x + dx, y + dy) for dy in (-1, 0, 1) for dx in (-1, 0, 1)] for x, y in self.base_center]
92
+ # 出生、重生优先使用离中心近的格子;每个基地的顺序在对称意义下相同
93
+ self.spawn_order = [sorted(b, key=lambda p, bc=bc: (distance(p, self.center),
94
+ abs(p[0] - bc[0]) + 2 * abs(p[1] - bc[1])))
95
+ for b, bc in zip(self.bases, self.base_center)]
96
+ self.walls = self.make_walls()
97
+ self.turn, self.done = 0, False
98
+ self.score = [0] * n
99
+ # 旗点:每个基地正前方各一个,外加地图正中心一个。旗子每次出现在随机一个
100
+ # 空旗点上,一局刷新几十次,任何座位都得不到固定的地利。
101
+ self.flag_spots = [tuple(p) for p in spots_between(self.base_center, self.center)] + [self.center]
102
+ self.flags = [{"id": i, "pos": None, "status": "home", "holder": None, "return_at": None}
103
+ for i in range(max(2, math.ceil(n / 2)))]
104
+ for f in self.flags:
105
+ f["pos"] = list(self.free_spot())
106
+ self.units = []
107
+ for team in range(n):
108
+ for p in self.spawn_order[team][:3]:
109
+ self.units.append({"id": len(self.units), "team": team, "pos": list(p), "hp": RULES["hp"],
110
+ "flag": None, "respawn_at": None, "immune": False})
111
+ self.stats = [Counter() for _ in range(n)]
112
+ self.events = []
113
+ self._sent_init = set()
114
+ self.check()
115
+
116
+ def free_spot(self):
117
+ taken = {tuple(f["pos"]) for f in self.flags if f["pos"] is not None and f["status"] == "home"}
118
+ return self.flag_rng.choice([p for p in self.flag_spots if p not in taken] or self.flag_spots)
119
+
120
+ def make_walls(self):
121
+ size = self.size
122
+ walls = {(x, y) for x in range(size) for y in range(size) if x in (0, size - 1) or y in (0, size - 1)}
123
+ keep = {self.center, *self.base_center, *spots_between(self.base_center, self.center)}
124
+ keep = {(x + dx, y + dy) for x, y in keep for dx in range(-2, 3) for dy in range(-2, 3)}
125
+ cells = [(x, y) for x in range(3, size - 3) for y in range(3, size - 3) if (x, y) not in keep]
126
+ self.rng.shuffle(cells)
127
+ pillars = []
128
+ for p in cells:
129
+ if self.rng.random() < 0.14 and all(distance(p, q) > 2 for q in pillars):
130
+ pillars.append(p)
131
+ return walls | set(pillars)
132
+
133
+ # ---------- 选手看到的信息 ----------
134
+
135
+ def observation(self, team):
136
+ return {"turn": self.turn, "team": team, "teams": self.n, "size": self.size,
137
+ "map": self.map_rows(), "center": list(self.center),
138
+ "bases": [[list(p) for p in b] for b in self.bases],
139
+ "flag_spots": [list(p) for p in self.flag_spots],
140
+ "units": [{k: u[k] for k in ("id", "team", "pos", "hp", "flag", "respawn_at")}
141
+ for u in self.units],
142
+ "flags": copy.deepcopy(self.flags), "score": self.score[:],
143
+ "events": copy.deepcopy(self.events), "rules": dict(RULES)}
144
+
145
+ # ---------- 文本协议 ----------
146
+
147
+ def encode(self, team):
148
+ """第 team 方本回合的输入。第一次调用时先附上 INIT 块。"""
149
+ out = []
150
+ if team not in self._sent_init:
151
+ self._sent_init.add(team)
152
+ r = RULES
153
+ out.append(f"INIT {self.n} {team} {self.size} {r['turns']} {r['hp']} {r['damage']} "
154
+ f"{r['respawn']} {r['flag_return']} {r['flag_cooldown']}")
155
+ out.append("MAP")
156
+ out.extend(self.map_rows())
157
+ out.append(f"BASES {self.n}")
158
+ out.extend(f"{x} {y}" for x, y in self.base_center)
159
+ out.append(f"SPOTS {len(self.flag_spots)}")
160
+ out.extend(f"{x} {y}" for x, y in self.flag_spots)
161
+ out.append(f"TURN {self.turn + 1}")
162
+ out.append("SCORE " + " ".join(map(str, self.score)))
163
+ out.append(f"UNITS {len(self.units)}")
164
+ for u in self.units:
165
+ x, y = u["pos"] if u["pos"] else (-1, -1)
166
+ out.append(f"U {u['id']} {u['team']} {x} {y} {u['hp']} "
167
+ f"{-1 if u['flag'] is None else u['flag']} {-1 if u['respawn_at'] is None else u['respawn_at']}")
168
+ out.append(f"FLAGS {len(self.flags)}")
169
+ for f in self.flags:
170
+ x, y = f["pos"] if f["pos"] else (-1, -1)
171
+ out.append(f"F {f['id']} {f['status']} {x} {y} {-1 if f['holder'] is None else f['holder']} "
172
+ f"{-1 if f['return_at'] is None else f['return_at']}")
173
+ out.append(f"EVENTS {len(self.events)}")
174
+ for e in self.events:
175
+ t = e["type"]
176
+ if t == "death":
177
+ by = e["by"]
178
+ out.append(f"E death {e['unit']} {e['pos'][0]} {e['pos'][1]} "
179
+ f"{-1 if e['flag'] is None else e['flag']} {len(by)} " + " ".join(map(str, by)))
180
+ elif t == "respawn":
181
+ out.append(f"E respawn {e['unit']} {e['pos'][0]} {e['pos'][1]}")
182
+ elif t == "attack":
183
+ out.append(f"E attack {e['unit']} {e['target']}")
184
+ elif t == "capture":
185
+ out.append(f"E capture {e['unit']} {e['team']} {e['flag']}")
186
+ elif t in ("pickup", "drop"):
187
+ out.append(f"E {t} {e['unit']} {e['flag']}")
188
+ else:
189
+ out.append(f"E return {e['flag']}")
190
+ out.append("END")
191
+ return "\n".join(out) + "\n"
192
+
193
+ def decode(self, team, line):
194
+ """解析一行回复:回合号,然后己方 3 个角色各一个移动、一个动作,可选 # 注释。"""
195
+ body, _, note = line.partition("#")
196
+ tok = body.split()
197
+ if not tok or tok[0] != str(self.turn + 1):
198
+ raise ValueError(f"行首应为本回合编号 {self.turn + 1}")
199
+ if len(tok) != 7:
200
+ raise ValueError("应为:回合号 + 3 个角色各一个移动和一个动作")
201
+ notes = [s.strip()[:20] for s in note.split(";")] if note.strip() else []
202
+ units = []
203
+ for k in range(3):
204
+ mv, act = tok[1 + 2 * k], tok[2 + 2 * k]
205
+ if mv not in TEXT_MOVES:
206
+ raise ValueError(f"移动只能是 U D L R S,收到 {mv}")
207
+ cmd = {"id": 3 * team + k, "move": TEXT_MOVES[mv]}
208
+ if act == "-":
209
+ cmd["act"] = "wait"
210
+ elif act == "P":
211
+ cmd["act"] = "pickup"
212
+ elif act == "X":
213
+ cmd["act"] = "drop"
214
+ elif act.isdigit():
215
+ cmd.update(act="attack", target=int(act))
216
+ else:
217
+ raise ValueError(f"动作只能是 - P X 或目标编号,收到 {act}")
218
+ if k < len(notes) and notes[k]:
219
+ cmd["note"] = notes[k]
220
+ units.append(cmd)
221
+ return {"units": units}
222
+
223
+ def map_rows(self):
224
+ if not hasattr(self, "_rows"):
225
+ self._rows = ["".join("#" if (x, y) in self.walls else "." for x in range(self.size))
226
+ for y in range(self.size)]
227
+ return self._rows
228
+
229
+ def header(self):
230
+ return {"version": VERSION, "seed": self.seed, "flag_seed": self.flag_seed, "teams": self.n, "size": self.size,
231
+ "map": self.map_rows(), "center": list(self.center),
232
+ "bases": [[list(p) for p in b] for b in self.bases],
233
+ "flag_spots": [list(p) for p in self.flag_spots], "rules": dict(RULES)}
234
+
235
+ # ---------- 结算 ----------
236
+
237
+ def can_attack(self, a, b):
238
+ if distance(a, b) > RULES["attack_range"]:
239
+ return False
240
+ dx, dy = b[0] - a[0], b[1] - a[1]
241
+ if abs(dx) == 2 and dy == 0:
242
+ return (a[0] + dx // 2, a[1]) not in self.walls
243
+ if abs(dy) == 2 and dx == 0:
244
+ return (a[0], a[1] + dy // 2) not in self.walls
245
+ if abs(dx) == abs(dy) == 1:
246
+ return (a[0] + dx, a[1]) not in self.walls or (a[0], a[1] + dy) not in self.walls
247
+ return True
248
+
249
+ def step(self, orders):
250
+ if self.done:
251
+ raise RuntimeError("比赛已结束")
252
+ self.turn += 1
253
+ events = []
254
+ commands, shown = {}, {}
255
+ for team in range(self.n):
256
+ rows = orders.get(team, {}).get("units") if isinstance(orders.get(team), dict) else None
257
+ for row in rows if isinstance(rows, list) else []:
258
+ if not isinstance(row, dict):
259
+ continue
260
+ uid = row.get("id")
261
+ if type(uid) is int and 0 <= uid < len(self.units) and self.units[uid]["team"] == team:
262
+ commands[uid] = row
263
+ shown[team] = []
264
+
265
+ # 1. 旗帜回位,阵亡角色重生
266
+ for f in self.flags:
267
+ if f["status"] in ("dropped", "cooldown") and self.turn >= f["return_at"]:
268
+ f.update(pos=list(self.free_spot()), status="home", holder=None, return_at=None)
269
+ events.append({"type": "return", "flag": f["id"]})
270
+ occupied = {tuple(u["pos"]) for u in self.units if u["pos"] is not None}
271
+ for u in self.units:
272
+ u["immune"] = False
273
+ if u["pos"] is None and self.turn >= u["respawn_at"]:
274
+ for p in self.spawn_order[u["team"]]:
275
+ if p not in occupied:
276
+ u.update(pos=list(p), hp=RULES["hp"], respawn_at=None, immune=True)
277
+ occupied.add(p)
278
+ events.append({"type": "respawn", "unit": u["id"], "pos": list(p)})
279
+ break
280
+
281
+ # 2. 同时移动
282
+ live = [u for u in self.units if u["pos"] is not None]
283
+ start = {u["id"]: tuple(u["pos"]) for u in live}
284
+ want = {}
285
+ for u in live:
286
+ move = commands.get(u["id"], {}).get("move", "WAIT")
287
+ dx, dy = MOVES.get(move, (0, 0)) if isinstance(move, str) else (0, 0)
288
+ p = (start[u["id"]][0] + dx, start[u["id"]][1] + dy)
289
+ want[u["id"]] = start[u["id"]] if p in self.walls else p
290
+ count = Counter(want.values())
291
+ blocked = {i for i, p in want.items() if count[p] > 1}
292
+ owner = {p: i for i, p in start.items()}
293
+ for i, p in want.items():
294
+ j = owner.get(p)
295
+ if j is not None and j != i and want[j] == start[i] and self.units[i]["team"] != self.units[j]["team"]:
296
+ blocked.update((i, j))
297
+ while True:
298
+ more = {i for i, p in want.items() if i not in blocked and p != start[i] and p in owner
299
+ and (owner[p] in blocked or want[owner[p]] == start[owner[p]])}
300
+ if not more:
301
+ break
302
+ blocked |= more
303
+ for u in live:
304
+ u["pos"] = list(start[u["id"]] if u["id"] in blocked else want[u["id"]])
305
+
306
+ # 3. 同时攻击,阵亡掉旗
307
+ hits = {}
308
+ for u in live:
309
+ cmd = commands.get(u["id"], {})
310
+ t = cmd.get("target")
311
+ if cmd.get("act") != "attack" or u["flag"] is not None or u["immune"]:
312
+ continue
313
+ if type(t) is not int or not 0 <= t < len(self.units):
314
+ continue
315
+ v = self.units[t]
316
+ if v["pos"] is not None and v["team"] != u["team"] and not v["immune"] and self.can_attack(u["pos"], v["pos"]):
317
+ hits.setdefault(t, []).append(u["id"])
318
+ events.append({"type": "attack", "unit": u["id"], "target": t})
319
+ for t, attackers in hits.items():
320
+ self.units[t]["hp"] -= RULES["damage"] * len(attackers)
321
+ for u in live:
322
+ if u["hp"] > 0:
323
+ continue
324
+ attackers = hits[u["id"]]
325
+ for a in attackers:
326
+ self.stats[self.units[a]["team"]]["kills"] += 1 / len(attackers)
327
+ if u["flag"] is not None:
328
+ self.stats[self.units[a]["team"]]["carrier_kills"] += 1 / len(attackers)
329
+ self.stats[u["team"]]["deaths"] += 1
330
+ events.append({"type": "death", "unit": u["id"], "pos": u["pos"][:], "by": attackers, "flag": u["flag"]})
331
+ if u["flag"] is not None:
332
+ self.flags[u["flag"]].update(pos=u["pos"][:], status="dropped", holder=None,
333
+ return_at=self.turn + RULES["flag_return"])
334
+ u.update(pos=None, hp=0, flag=None, respawn_at=self.turn + RULES["respawn"])
335
+
336
+ # 4. 拾旗(同一格有多面旗时拿编号最小的)
337
+ for u in self.units:
338
+ if u["pos"] is None or u["flag"] is not None or commands.get(u["id"], {}).get("act") != "pickup":
339
+ continue
340
+ for f in self.flags:
341
+ if f["status"] in ("home", "dropped") and f["pos"] == u["pos"]:
342
+ u["flag"] = f["id"]
343
+ f.update(pos=None, status="carried", holder=u["id"], return_at=None)
344
+ self.stats[u["team"]]["pickups"] += 1
345
+ events.append({"type": "pickup", "unit": u["id"], "flag": f["id"]})
346
+ break
347
+
348
+ # 5. 主动丢旗
349
+ for u in self.units:
350
+ if u["pos"] is not None and u["flag"] is not None and commands.get(u["id"], {}).get("act") == "drop":
351
+ f = self.flags[u["flag"]]
352
+ f.update(pos=u["pos"][:], status="dropped", holder=None, return_at=self.turn + RULES["flag_return"])
353
+ events.append({"type": "drop", "unit": u["id"], "flag": f["id"]})
354
+ u["flag"] = None
355
+
356
+ # 6. 交旗
357
+ for u in self.units:
358
+ if u["pos"] is not None and u["flag"] is not None and tuple(u["pos"]) in self.bases[u["team"]]:
359
+ f = self.flags[u["flag"]]
360
+ f.update(status="cooldown", pos=None, holder=None, return_at=self.turn + RULES["flag_cooldown"])
361
+ self.score[u["team"]] += 1
362
+ self.stats[u["team"]]["captures"] += 1
363
+ events.append({"type": "capture", "unit": u["id"], "team": u["team"], "flag": f["id"]})
364
+ u["flag"] = None
365
+
366
+ self.events = events
367
+ self.done = self.turn >= RULES["turns"]
368
+ self.check()
369
+ for uid, cmd in commands.items():
370
+ shown[self.units[uid]["team"]].append({k: cmd[k] for k in ("id", "move", "act", "target", "note")
371
+ if k in cmd and len(str(cmd[k])) <= 40})
372
+ return self.frame(shown)
373
+
374
+ def frame(self, orders=None):
375
+ return {"turn": self.turn, "score": self.score[:], "events": copy.deepcopy(self.events),
376
+ "units": [{k: u[k] for k in ("id", "pos", "hp", "flag")} for u in self.units],
377
+ "flags": copy.deepcopy(self.flags), "orders": orders or {}}
378
+
379
+ def check(self):
380
+ at = [tuple(u["pos"]) for u in self.units if u["pos"] is not None]
381
+ assert len(at) == len(set(at)) and not set(at) & self.walls
382
+ for f in self.flags:
383
+ holders = [u for u in self.units if u["flag"] == f["id"]]
384
+ assert (f["status"] == "carried") == bool(holders) and len(holders) <= 1
385
+ assert all(self.score[t] == self.stats[t]["captures"] for t in range(self.n))
386
+
387
+ def result(self):
388
+ return {"score": self.score[:], "turns": self.turn,
389
+ "stats": [{k: round(v, 2) for k, v in s.items()} for s in self.stats]}
@@ -0,0 +1,24 @@
1
+ @echo off
2
+ setlocal
3
+ chcp 65001 >nul
4
+ rem 找一个 Python 3.10 以上,在本目录运行给定的脚本。不带参数时显示开发用时。
5
+ rem 用 call 调用:python3 等可能是 .cmd 转发脚本,不加 call 会一去不回。
6
+ set "PY="
7
+ for %%P in (python python3) do if not defined PY (
8
+ call %%P -c "import sys; sys.exit(sys.version_info < (3, 10))" >nul 2>nul && set "PY=%%P"
9
+ )
10
+ if not defined PY (
11
+ call py -3 -c "import sys; sys.exit(sys.version_info < (3, 10))" >nul 2>nul && set "PY=py -3"
12
+ )
13
+ if not defined PY if exist "%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" (
14
+ set "PY="%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe""
15
+ )
16
+ if not defined PY (
17
+ echo 没有找到 Python 3.10 以上的版本。请把情况告诉组织方。
18
+ exit /b 1
19
+ )
20
+ pushd "%~dp0"
21
+ if "%~1"=="" (call %PY% -X utf8 clock.py) else (call %PY% -X utf8 %*)
22
+ set "CODE=%errorlevel%"
23
+ popd
24
+ exit /b %CODE%