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,1426 @@
1
+ """正式赛(坦克大战,1 对 1):冻结赛程 → 跑(可中断、可续跑)→ 评分。
2
+
3
+ python tournament.py plan 赛事配置.json 冻结赛程;已有赛程时拒绝覆盖
4
+ python tournament.py run 赛事目录 [--workers N] 开赛前统一编译,绑核,逐批测速;随时可以中断,再运行接着跑
5
+ python tournament.py rate 赛事目录 输出 成绩.md、成绩.json
6
+ python tournament.py status 赛事目录 进度、顶替、测速标记
7
+ python tournament.py cpus 赛事配置.json 按本机拓扑和配置算出的绑核方案(不运行任何对局)
8
+ python tournament.py estimate [--programs 14] ... N(每对的地图数)与耗时的对照表
9
+
10
+ 赛事配置(JSON,路径相对配置文件所在目录):
11
+ title 这场比赛叫什么(写进成绩表)
12
+ kit 选手包目录的副本(tank.hpp、tank_main.cpp、core.py、engine.py、judge/)。必须是纯英文路径
13
+ build_dir 编译结果放哪里。必须在允许运行新程序的位置(本机是 C:/bench 下)
14
+ out 赛事目录:赛程、结果、回放、清单、成绩都在这里
15
+ compiler g++ 的路径(可选;不写就按 core.find_compiler 的顺序找)
16
+ players {名字: 源码或可执行文件}
17
+ substitutes {名字: [替补, ...], "*": [替补, ...]}。某个程序没有文件或编译不过时,依次试它名下的替补,再试 "*" 的。
18
+ 替补写路径,或 {"path": 路径, "label": "说明"}。都不行这场比赛才停下
19
+ anchor 基准的名字,实力值以它为 0
20
+ unranked 不排名次的名字(基准自动不排;陪练写在这里)
21
+ labels {名字: 显示时附带的说明},比如 {"baseline": "基准"}
22
+ display {名字: 显示名}。成绩表里写成“显示名(名字)”;没有写的直接显示名字。只影响显示,文件名、结果文件里都还是名字
23
+ maps_per_pair 每对打几张图(每张图换边各一局,所以每对的局数是它的两倍)
24
+ pairs 不写 = 全配对;写成 [[a, b], ...] 就只打这些对
25
+ seed 赛程的随机种子(不写就随机抽,抽到的值写进赛程冻结)
26
+ avoid_seeds 别的赛程文件的列表:不使用它们用过的地图种子
27
+ maps_from 另一场比赛的赛事目录:不抽地图,第 m 张图用那场比赛第 m 张图的种子和风格(图数不能比那场多)
28
+ borrow {"from": 另一场比赛的赛事目录, "names": [名字, ...]}:这些程序两两之间的对局不重打,直接借用那场比赛
29
+ 已有的结果(要求 maps_from 指向同一场,图才对得上)。冻结赛程时把借来的结果抄进 借用结果.jsonl;
30
+ 评分时和自己打的结果合在一起。run 时核对:引擎和裁判的 7 个文件、编译器、这些程序的源码哈希都和
31
+ 那场比赛冻结的一致,不一致就拒绝(借来的结果和现打的结果必须是同一套东西打出来的)
32
+ styles 地图风格数(默认 4),图按序号轮换风格
33
+ workers 同时打几局。0 = 绑核方案给出几个位置就用几个
34
+ pin {"mode": "big" | "big+small" | "none"}。big:只用大核,一个物理核只放一个 bot(默认);
35
+ big+small:小核也用,同一局的两个 bot 一定在同种核上;none:不绑核。
36
+ 也可以直接写 {"slots": [[0 号位的逻辑处理器, 1 号位的], ...]}
37
+ batch_maps 几张图算一批(默认 1)。每批开始前、结束后各测一次速
38
+ speed {"enabled": true, "tolerance": 0.10, "repeat": 3, "warmup_seconds": 15, "reference_batches": 3}
39
+ warmup_seconds:每次 run 开打前先在 bot 要用的处理器上连续测速这么多秒再取读数(冷机时的读数偏快);
40
+ reference_batches:已完成的批次达到这个数之后,"偏离基准"里的基准取这些批次开始时读数的中位数
41
+ replays 是否保存回放(默认 true,gzip)
42
+ boot 区间重抽样的次数(默认 1000)
43
+ retries 裁判这一侧出错时一局最多重打几次(默认 2)
44
+ compile_workers 统一编译时同时编几个(默认 4)
45
+
46
+ 赛事目录里的文件:
47
+ 赛程.json 冻结的配置、种子、每一局的图和座位
48
+ 清单.json 第一次 run 时冻结:每个程序的哈希和顶替状态、选手包各文件的哈希、编译器、拓扑和绑核方案;
49
+ 之后每次 run 追加一段起止时间。续跑前逐项核对,不一致就拒绝
50
+ 结果.jsonl 每局一行
51
+ 回放/ 每局一个 .json.gz(和选手本地的回放是同一种格式)
52
+ 测速.jsonl 每批前后的测速和判定
53
+ 借用结果.jsonl 配置里写了 borrow 时:从另一场比赛抄来的结果(每行多一个 borrowed_from)
54
+ 裁判错误.jsonl 裁判这一侧出错、重打仍不成的局(这些局没有结果,下次 run 会再打)
55
+ 作废.jsonl run --redo-flagged 作废掉的结果
56
+ 编译记录/ 编译不过的程序的编译器输出
57
+ 成绩.md、成绩.json
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import argparse
62
+ import hashlib
63
+ import importlib
64
+ import json
65
+ import math
66
+ import os
67
+ import queue
68
+ import secrets
69
+ import statistics
70
+ import subprocess
71
+ import sys
72
+ import threading
73
+ import time
74
+ from pathlib import Path
75
+
76
+ HERE = Path(__file__).resolve().parent
77
+ sys.path.insert(0, str(HERE))
78
+ import bt # noqa: E402
79
+ import schedule # noqa: E402
80
+
81
+ SCHEDULE, RESULTS, MANIFEST, SPEED = "赛程.json", "结果.jsonl", "清单.json", "测速.jsonl"
82
+ ERRORS, VOID, REPLAYS, BUILDLOG = "裁判错误.jsonl", "作废.jsonl", "回放", "编译记录"
83
+ BORROWED = "借用结果.jsonl"
84
+ KIT_FILES = ["tank.hpp", "tank_main.cpp", "core.py", "engine.py", "judge/referee.cpp", "judge/cycles.hpp",
85
+ "judge/speed.cpp"]
86
+ GAME_TIMEOUT = 1200 # 一局的墙上时间超过这么多秒,认为裁判进程卡死,结束它重打
87
+ CROWDED_RATIO = 1.15 # 测速时"墙上时间 / CPU 时间"超过它,认为这个处理器上有别的任务
88
+
89
+
90
+ class Refuse(SystemExit):
91
+ """拒绝执行:把原因说清楚再退出。"""
92
+
93
+ def __init__(self, text):
94
+ super().__init__("拒绝执行:" + text)
95
+
96
+
97
+ def setup_console():
98
+ for stream in (sys.stdout, sys.stderr):
99
+ try:
100
+ stream.reconfigure(encoding="utf-8", errors="replace")
101
+ except (AttributeError, ValueError):
102
+ pass
103
+
104
+
105
+ def sha_file(path):
106
+ h = hashlib.sha256()
107
+ with open(path, "rb") as f:
108
+ for chunk in iter(lambda: f.read(1 << 20), b""):
109
+ h.update(chunk)
110
+ return h.hexdigest()
111
+
112
+
113
+ def read_json(path):
114
+ return json.loads(Path(path).read_text(encoding="utf-8-sig"))
115
+
116
+
117
+ def write_json(path, data):
118
+ """先写临时文件再改名,中途被打断也不会留下半个文件。"""
119
+ path = Path(path)
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ tmp = path.with_name(f"tmp-{os.getpid()}-{path.name}")
122
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
123
+ replace_file(tmp, path)
124
+
125
+
126
+ def replace_file(tmp, path):
127
+ """改名覆盖。Windows 上目标文件正被别的进程打开着读时会被拒绝,等一下再试(最多约 3 秒)。"""
128
+ for attempt in range(60):
129
+ try:
130
+ os.replace(tmp, path)
131
+ return
132
+ except PermissionError:
133
+ if attempt == 59:
134
+ raise
135
+ time.sleep(0.05)
136
+
137
+
138
+ def append_line(path, record):
139
+ """追加一行 JSON:一次系统调用写完,几个进程同时写也不会互相打断。"""
140
+ path = Path(path)
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ line = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
143
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o644)
144
+ try:
145
+ os.write(fd, line)
146
+ finally:
147
+ os.close(fd)
148
+
149
+
150
+ def read_lines(path):
151
+ path = Path(path)
152
+ if not path.is_file():
153
+ return []
154
+ return [json.loads(x) for x in path.read_text(encoding="utf-8").splitlines() if x.strip()]
155
+
156
+
157
+ def stamp(t=None):
158
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t))
159
+
160
+
161
+ # ====================================================================
162
+ # 选手包(引擎、主循环、裁判)
163
+ # ====================================================================
164
+
165
+ _kit = {}
166
+
167
+
168
+ def load_kit(kit, compiler=None):
169
+ """从选手包目录的副本里导入 core 和 engine。返回 (core, engine)。"""
170
+ kit = Path(kit).resolve()
171
+ if not all(ord(ch) < 128 for ch in str(kit)):
172
+ raise Refuse(f"选手包目录必须是纯英文路径(编译器打不开含中文的路径):{kit}")
173
+ for name in KIT_FILES:
174
+ if not (kit / name).is_file():
175
+ raise Refuse(f"选手包目录不完整,缺 {name}:{kit}")
176
+ if compiler:
177
+ if not Path(compiler).is_file():
178
+ raise Refuse(f"配置里的编译器不存在:{compiler}")
179
+ os.environ["CXX"] = str(Path(compiler).resolve())
180
+ if _kit.get("path") == kit:
181
+ return _kit["core"], _kit["engine"]
182
+ if "core" in sys.modules or "engine" in sys.modules:
183
+ loaded = Path(sys.modules.get("core", sys.modules.get("engine")).__file__).resolve().parent
184
+ if loaded != kit:
185
+ raise Refuse(f"core / engine 已经从别处导入({loaded}),不能再换成 {kit}")
186
+ sys.path.insert(0, str(kit))
187
+ core = importlib.import_module("core")
188
+ engine = importlib.import_module("engine")
189
+ if Path(core.__file__).resolve().parent != kit:
190
+ raise Refuse(f"导入的 core.py 不是选手包里的那一份:{core.__file__}")
191
+ _kit.update(path=kit, core=core, engine=engine)
192
+ return core, engine
193
+
194
+
195
+ def kit_hashes(kit):
196
+ return {name: sha_file(Path(kit) / name) for name in KIT_FILES}
197
+
198
+
199
+ def compiler_info(core):
200
+ cxx = core.find_compiler()
201
+ if not cxx:
202
+ raise Refuse("没有找到 C++ 编译器。在配置里用 compiler 指明 g++ 的路径。")
203
+ env = dict(os.environ)
204
+ env["PATH"] = str(Path(cxx).resolve().parent) + os.pathsep + env.get("PATH", "")
205
+ try:
206
+ r = subprocess.run([cxx, "--version"], capture_output=True, encoding="utf-8", errors="replace", timeout=60,
207
+ env=env, stdin=subprocess.DEVNULL)
208
+ version = (r.stdout or r.stderr).splitlines()[0].strip()
209
+ except (OSError, subprocess.TimeoutExpired, IndexError) as exc:
210
+ raise Refuse(f"编译器无法运行:{cxx}({exc})")
211
+ return {"path": str(cxx), "version": version, "sha256": sha_file(cxx), "flags": " ".join(core.CXX_FLAGS)}
212
+
213
+
214
+ # ====================================================================
215
+ # plan:冻结赛程
216
+ # ====================================================================
217
+
218
+ def resolve_config(cfg, base):
219
+ """把配置里的相对路径换成绝对路径(相对配置文件所在目录)。"""
220
+ base = Path(base)
221
+
222
+ def full(p):
223
+ return str((base / p).resolve())
224
+
225
+ out = dict(cfg)
226
+ for key in ("kit", "build_dir", "out", "compiler"):
227
+ if out.get(key):
228
+ out[key] = full(out[key])
229
+ out["players"] = {n: full(p) for n, p in cfg["players"].items()}
230
+ subs = {}
231
+ for name, chain in (cfg.get("substitutes") or {}).items():
232
+ subs[name] = []
233
+ for item in chain if isinstance(chain, list) else [chain]:
234
+ item = {"path": item} if isinstance(item, str) else dict(item)
235
+ item["path"] = full(item["path"])
236
+ item.setdefault("label", Path(item["path"]).name)
237
+ subs[name].append(item)
238
+ out["substitutes"] = subs
239
+ out["avoid_seeds"] = [full(p) for p in cfg.get("avoid_seeds") or []]
240
+ if out.get("maps_from"):
241
+ out["maps_from"] = full(out["maps_from"])
242
+ if out.get("borrow"):
243
+ out["borrow"] = {**out["borrow"], "from": full(out["borrow"]["from"])}
244
+ return out
245
+
246
+
247
+ def map_table(games):
248
+ """赛程里的地图:{图的序号: (种子, 风格)}。"""
249
+ return {g["map"]: (g["map_seed"], g["style"]) for g in games}
250
+
251
+
252
+ def borrow_rows(cfg, games):
253
+ """按配置里的 borrow 从另一场比赛取结果。返回 (结果行的列表, 写进赛程的摘要)。对不上就拒绝。
254
+
255
+ 取的是 names 里的程序两两之间、图的序号在本场范围内的全部对局;要求每一对在每张图上正好换边两局、
256
+ 这些图在两场比赛里的种子和风格完全相同、本场的赛程里没有再排这些对。"""
257
+ spec = cfg["borrow"]
258
+ src = Path(spec["from"])
259
+ names = sorted(set(spec.get("names") or []))
260
+ if len(names) < 2:
261
+ raise Refuse("borrow.names 至少要有两个程序")
262
+ for n in names:
263
+ if n not in cfg["players"]:
264
+ raise Refuse(f"borrow.names 里的 {n} 不在参赛名单里")
265
+ if src.resolve() == Path(cfg["out"]).resolve():
266
+ raise Refuse("borrow.from 不能是这场比赛自己")
267
+ if not (src / SCHEDULE).is_file() or not (src / RESULTS).is_file() or not (src / MANIFEST).is_file():
268
+ raise Refuse(f"要借用结果的那场比赛不完整(缺赛程、结果或清单):{src}")
269
+ theirs, mine = map_table(read_json(src / SCHEDULE)["games"]), map_table(games)
270
+ for m, value in sorted(mine.items()):
271
+ if theirs.get(m) != value:
272
+ raise Refuse(f"第 {m} 张图在两场比赛里不是同一张(这里 {value},那场 {theirs.get(m)}),不能借用结果。"
273
+ "借用结果时 maps_from 要指向同一场比赛。")
274
+ wanted = set(names)
275
+ clash = sorted({tuple(sorted(g["seats"])) for g in games if set(g["seats"]) <= wanted})
276
+ if clash:
277
+ raise Refuse(f"这些对已经借用了结果,赛程里不应该再排:{clash[:5]}")
278
+ rows = [r for r in read_lines(src / RESULTS) if r["map"] in mine and set(r["seats"]) <= wanted]
279
+ count = {}
280
+ for r in rows:
281
+ if (r["map_seed"], r["style"]) != mine[r["map"]]:
282
+ raise Refuse(f"那场比赛的结果 {r['id']} 记的地图和它的赛程对不上")
283
+ count[r["map"], tuple(r["seats"])] = count.get((r["map"], tuple(r["seats"])), 0) + 1
284
+ need = [(m, a, b) for m in mine for a in names for b in names if a != b]
285
+ bad = [k for k in need if count.get((k[0], (k[1], k[2])), 0) != 1]
286
+ if bad:
287
+ raise Refuse(f"那场比赛里这些对局不是正好一局(图, 0 号位, 1 号位),借不全:{bad[:5]}"
288
+ f"{' ……' if len(bad) > 5 else ''}(共 {len(bad)} 处)")
289
+ rows = [{**r, "borrowed_from": str(src)} for r in rows]
290
+ return rows, {"from": str(src), "names": names, "rows": len(rows), "source_results_sha": sha_file(src / RESULTS)}
291
+
292
+
293
+ def plan(cfg, base=".", quiet=False):
294
+ """cfg:配置字典。冻结赛程,返回赛事目录。已有赛程时拒绝。"""
295
+ cfg = resolve_config(cfg, base)
296
+ for key in ("kit", "build_dir", "out", "players", "maps_per_pair"):
297
+ if not cfg.get(key):
298
+ raise Refuse(f"赛事配置里缺 {key}")
299
+ out = Path(cfg["out"])
300
+ if (out / SCHEDULE).exists():
301
+ raise Refuse(f"赛程已存在,不覆盖:{out / SCHEDULE}\n要重排请换一个赛事目录(或者确认这场比赛不要了,手工删掉整个目录)。")
302
+ names = sorted(cfg["players"])
303
+ anchor = cfg.get("anchor")
304
+ if anchor is not None and anchor not in names:
305
+ raise Refuse(f"基准 {anchor} 不在参赛名单里")
306
+ for n in cfg.get("unranked") or []:
307
+ if n not in names:
308
+ raise Refuse(f"unranked 里的 {n} 不在参赛名单里")
309
+ for n in cfg["substitutes"]:
310
+ if n != "*" and n not in names:
311
+ raise Refuse(f"substitutes 里的 {n} 不在参赛名单里")
312
+ seed = cfg.get("seed")
313
+ if seed is None:
314
+ seed = secrets.randbits(48)
315
+ avoid = set()
316
+ for path in cfg["avoid_seeds"]:
317
+ avoid |= {g["map_seed"] for g in read_json(path)["games"]}
318
+ map_seeds = None
319
+ if cfg.get("maps_from"):
320
+ src = Path(cfg["maps_from"]) / SCHEDULE
321
+ if not src.is_file():
322
+ raise Refuse(f"maps_from 指的那场比赛没有赛程:{src}")
323
+ theirs = map_table(read_json(src)["games"])
324
+ if sorted(theirs) != list(range(len(theirs))):
325
+ raise Refuse(f"{src} 里图的序号不连续,没法照着用")
326
+ if int(cfg["maps_per_pair"]) > len(theirs):
327
+ raise Refuse(f"要打 {cfg['maps_per_pair']} 张图,而 maps_from 指的那场比赛只有 {len(theirs)} 张")
328
+ styles = int(cfg.get("styles", 4))
329
+ if any(theirs[m][1] != m % styles for m in theirs):
330
+ raise Refuse(f"{src} 里图的风格不是按序号轮换 {styles} 种排的,没法照着用")
331
+ map_seeds = [theirs[m][0] for m in range(len(theirs))]
332
+ if avoid & set(map_seeds[:int(cfg["maps_per_pair"])]):
333
+ raise Refuse("maps_from 给的地图和 avoid_seeds 里要避开的地图有重复")
334
+ try:
335
+ games = schedule.make_schedule(names, int(cfg["maps_per_pair"]), seed, cfg.get("pairs"),
336
+ int(cfg.get("styles", 4)), avoid, map_seeds)
337
+ except ValueError as exc:
338
+ raise Refuse(f"赛程排不出来:{exc}")
339
+ borrowed = None
340
+ if cfg.get("borrow"):
341
+ borrowed, cfg["borrow"] = borrow_rows(cfg, games)
342
+ doc = {**cfg, "seed": seed, "planned": stamp(), "audit": schedule.audit(games),
343
+ "games_sha": schedule.games_hash(games), "games": games}
344
+ if borrowed is not None:
345
+ # 先写借来的结果、再写赛程:赛程在,借用结果就一定是完整的。
346
+ out.mkdir(parents=True, exist_ok=True)
347
+ tmp = out / f"tmp-{os.getpid()}-{BORROWED}"
348
+ tmp.write_bytes(b"".join((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8") for r in borrowed))
349
+ replace_file(tmp, out / BORROWED)
350
+ doc["borrow"]["sha256"] = sha_file(out / BORROWED)
351
+ write_json(out / SCHEDULE, doc)
352
+ if not quiet:
353
+ a = doc["audit"]
354
+ print(f"已冻结赛程:{out / SCHEDULE}")
355
+ print(f" {len(names)} 个程序,{a['pairs']} 对,{a['maps']} 张图,共 {a['games']} 局;"
356
+ f"每对 {a['games_per_pair_min']}–{a['games_per_pair_max']} 局,"
357
+ f"换边{'均衡' if a['swap_balanced'] else '不均衡'},各风格图数 {a['maps_per_style']},赛程种子 {seed}")
358
+ if cfg.get("maps_from"):
359
+ print(f" 地图照搬自 {cfg['maps_from']}(前 {a['maps']} 张,种子和风格相同)")
360
+ if borrowed is not None:
361
+ print(f" 借用 {len(borrowed)} 局已有的结果({'、'.join(doc['borrow']['names'])} 两两之间,不重打):{out / BORROWED}")
362
+ return out
363
+
364
+
365
+ def borrowed_rows(out, doc):
366
+ """这场比赛借来的结果(没有借用返回空列表)。文件被动过就拒绝。"""
367
+ spec = doc.get("borrow")
368
+ if not spec:
369
+ return []
370
+ path = Path(out) / BORROWED
371
+ if not path.is_file() or sha_file(path) != spec.get("sha256"):
372
+ raise Refuse(f"{path} 不见了或者被改过(和冻结赛程时记的哈希不一样)。")
373
+ return read_lines(path)
374
+
375
+
376
+ def check_borrow(doc, kit, compiler, entries):
377
+ """借来的结果和现打的结果必须是同一套东西打出来的。返回不一致的说明列表(空 = 一致)。"""
378
+ spec = doc.get("borrow")
379
+ if not spec:
380
+ return []
381
+ theirs = read_json(Path(spec["from"]) / MANIFEST)["frozen"]
382
+ bad = []
383
+ for name, digest in theirs["kit"].items():
384
+ if kit.get(name) != digest:
385
+ bad.append(f"选手包里的 {name} 和那场比赛用的不是同一份(那场 {digest[:12]},现在 {str(kit.get(name))[:12]})")
386
+ for key, text in (("sha256", "编译器"), ("flags", "编译参数")):
387
+ if theirs["compiler"][key] != compiler[key]:
388
+ bad.append(f"{text}和那场比赛不一样:那场 {theirs['compiler'][key]},现在 {compiler[key]}")
389
+ for name in spec["names"]:
390
+ old, new = theirs["programs"].get(name), entries.get(name)
391
+ if old is None:
392
+ bad.append(f"那场比赛里没有 {name}")
393
+ elif new is None or old["used_sha"] != new["used_sha"]:
394
+ bad.append(f"{name} 的源码和那场比赛上场的不是同一份(那场 {old['used_sha'][:12]},"
395
+ f"现在 {str((new or {}).get('used_sha'))[:12]})")
396
+ return bad
397
+
398
+
399
+ # ====================================================================
400
+ # 绑核与测速
401
+ # ====================================================================
402
+
403
+ def pin_plan(core, cfg, workers=0):
404
+ """按本机拓扑和配置算出绑核方案。
405
+
406
+ 返回 {"mode", "slots": [{"class": 说明, "cpus": [[0 号位的逻辑处理器], [1 号位的]]} 或 None, ...],
407
+ "referee_cpus": 裁判进程用的逻辑处理器(bot 用到的物理核全部让开)或 None, "note"}。"""
408
+ pin = cfg.get("pin") or {}
409
+ mode = pin.get("mode", "big")
410
+ workers = int(workers or cfg.get("workers") or 0)
411
+ topo = core.cpu_topology()
412
+ everything = sorted(c for k in topo for c in k["cpus"])
413
+ note = ""
414
+ if pin.get("slots"):
415
+ slots = [{"class": "指定", "cpus": [[int(a)] if isinstance(a, int) else [int(x) for x in a],
416
+ [int(b)] if isinstance(b, int) else [int(x) for x in b]]}
417
+ for a, b in pin["slots"]]
418
+ mode = "slots"
419
+ elif mode == "none" or not topo:
420
+ if mode != "none":
421
+ note = "这个系统上读不到处理器拓扑,没有绑核。"
422
+ return {"mode": "none", "slots": [None] * (workers or 4), "referee_cpus": None, "note": note}
423
+ else:
424
+ if mode not in ("big", "big+small"):
425
+ raise Refuse(f"pin.mode 只能是 big、big+small、none,收到 {mode}")
426
+ classes = sorted({k["class"] for k in topo}, reverse=True)
427
+ slots = []
428
+ for cls in classes if mode == "big+small" else classes[:1]:
429
+ label = "同构核" if len(classes) == 1 else ("大核" if cls == classes[0] else "小核")
430
+ cpus = [k["cpus"][0] for k in topo if k["class"] == cls] # 一个物理核只取一个逻辑处理器
431
+ slots += [{"class": label, "cpus": [[cpus[i]], [cpus[i + 1]]]} for i in range(0, len(cpus) - 1, 2)]
432
+ if any(c > 63 for s in slots for side in s["cpus"] for c in side):
433
+ raise Refuse("绑核只支持编号 0–63 的逻辑处理器")
434
+ if workers:
435
+ if workers > len(slots):
436
+ note = f"要求同时打 {workers} 局,但绑核方案只有 {len(slots)} 个位置(不超卖),按 {len(slots)} 局跑。"
437
+ slots = slots[:workers]
438
+ used = {c for s in slots for side in s["cpus"] for c in side}
439
+ blocked = set(used)
440
+ for k in topo: # bot 所在物理核的另一个超线程也空出来
441
+ if used & set(k["cpus"]):
442
+ blocked |= set(k["cpus"])
443
+ referee = [c for c in everything if c not in blocked and c <= 63]
444
+ return {"mode": mode, "slots": slots, "referee_cpus": referee or None, "note": note}
445
+
446
+
447
+ def describe_plan(plan_):
448
+ if plan_["mode"] == "none":
449
+ return f"不绑核,同时 {len(plan_['slots'])} 局"
450
+ parts = [f"{s['class']} {'+'.join(map(str, s['cpus'][0]))}:{'+'.join(map(str, s['cpus'][1]))}" for s in plan_["slots"]]
451
+ ref = plan_["referee_cpus"]
452
+ return (f"同时 {len(parts)} 局,每局两个 bot 各占一个物理核:" + " ".join(parts)
453
+ + (f";裁判进程放在其余 {len(ref)} 个逻辑处理器上" if ref else ""))
454
+
455
+
456
+ def measure_speed(core, plan_, repeat=3):
457
+ """在 bot 要用的每个逻辑处理器上同时各跑一次测速程序。返回 {"time", "cpus": {编号: {...}}, "by_class": {...}}。"""
458
+ try:
459
+ exe = core.build_tool("speed")
460
+ except RuntimeError as exc:
461
+ raise Refuse(f"测速程序编译不了:{exc}")
462
+ targets = []
463
+ for s in plan_["slots"]:
464
+ if s is None:
465
+ continue
466
+ for side in s["cpus"]:
467
+ targets.append((side[0], s["class"]))
468
+ if not targets:
469
+ targets = [(None, "不绑核")]
470
+ results = {}
471
+
472
+ def one(cpu):
473
+ cmd = [str(exe), "--repeat", str(repeat)] + (["--cpu", str(cpu)] if cpu is not None else [])
474
+ try:
475
+ r = subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace", timeout=300,
476
+ stdin=subprocess.DEVNULL)
477
+ words = dict(w.split("=", 1) for w in r.stdout.split()[1:] if "=" in w)
478
+ results[cpu] = {"ghz": float(words["ghz"]), "engine_ms": float(words["engine_ms"]),
479
+ "engine_cpu_ms": float(words["engine_cpu_ms"]), "loop_ms": float(words["loop_ms"])}
480
+ except Exception as exc: # 测速失败不该让比赛停下,记下来
481
+ results[cpu] = {"error": f"{type(exc).__name__}: {exc}"[:200]}
482
+
483
+ threads = [threading.Thread(target=one, args=(cpu,)) for cpu, _ in targets]
484
+ began = time.time()
485
+ for t in threads:
486
+ t.start()
487
+ for t in threads:
488
+ t.join()
489
+ by_class = {}
490
+ for cpu, label in targets:
491
+ m = results[cpu]
492
+ if "error" not in m:
493
+ by_class.setdefault(label, []).append(m)
494
+ summary = {label: {"cpus": len(ms), "ghz": round(statistics.median(m["ghz"] for m in ms), 3),
495
+ "engine_ms": round(statistics.median(m["engine_ms"] for m in ms), 2)}
496
+ for label, ms in by_class.items()}
497
+ # 墙上时间比 CPU 时间长出一截,说明这个处理器上还有别的任务在跑(测速程序被挤下去过)。
498
+ crowded = sorted(cpu for cpu, _ in targets
499
+ if cpu is not None and "error" not in results[cpu] and results[cpu]["engine_cpu_ms"] > 0
500
+ and results[cpu]["engine_ms"] / results[cpu]["engine_cpu_ms"] > CROWDED_RATIO)
501
+ return {"time": stamp(began), "epoch": round(began, 1), "seconds": round(time.time() - began, 1),
502
+ "cpus": {str(cpu): results[cpu] for cpu, _ in targets}, "by_class": summary, "crowded": crowded,
503
+ "errors": sum(1 for m in results.values() if "error" in m)}
504
+
505
+
506
+ def warm_up(core, plan_, seconds, repeat=3):
507
+ """开打前预热:在 bot 要用的处理器上连续测速 seconds 秒,读数丢掉不用。返回 {"seconds", "readings": [各次的读数]}。
508
+
509
+ 为什么要:机器歇着的时候第一次测速偏快(第一场比赛:冷机 71 毫秒,跑起来之后稳定在 81 上下),
510
+ 拿它当基准,后面的批次会成片地"偏离基准"。"""
511
+ began, readings = time.time(), []
512
+ while seconds > 0 and time.time() - began < seconds:
513
+ m = measure_speed(core, plan_, repeat)
514
+ readings.append({label: v["engine_ms"] for label, v in m["by_class"].items()})
515
+ if not m["by_class"]:
516
+ break
517
+ return {"seconds": round(time.time() - began, 1), "readings": readings}
518
+
519
+
520
+ def speed_baseline(rows, opening, min_batches=3):
521
+ """这一批"开始时的读数"要和谁比。返回 (基准读数, 说明)。
522
+
523
+ rows:测速.jsonl 里已有的记录。作废的、开始时测到处理器上有别的任务的不算;其余批次达到 min_batches 个时,基准是它们开始时读数的中位数
524
+ (各类核、主频和固定工作量用时分别取中位数);不够时用开赛时(预热之后)的那次读数 opening。
525
+ 用中位数的理由:单独哪一次读数偏了(冷机、别的任务抢了一下)不会把基准带偏;机器状态真的整体变了
526
+ (比如续跑时电源模式换了)则新的批次会一直被标记,直到它们成为多数。"""
527
+ usable = [r["before"] for r in rows if not r.get("voided") and r.get("before") and r["before"].get("by_class")
528
+ and not r["before"].get("crowded")] # 测的时候处理器上有别的任务:这个读数不是机器本身的状态,不进基准
529
+ if len(usable) < max(1, int(min_batches)):
530
+ return opening, "开赛时预热后的读数"
531
+ by_class = {}
532
+ for label in sorted({k for m in usable for k in m["by_class"]}):
533
+ ms = [m["by_class"][label] for m in usable if label in m["by_class"]]
534
+ by_class[label] = {"cpus": ms[-1].get("cpus"), "ghz": round(statistics.median(x["ghz"] for x in ms), 3),
535
+ "engine_ms": round(statistics.median(x["engine_ms"] for x in ms), 2)}
536
+ return {"by_class": by_class, "batches": len(usable)}, f"已完成的 {len(usable)} 批开始时读数的中位数"
537
+
538
+
539
+ def replay_speed_flags(rows, tolerance, min_batches=3, opening=None):
540
+ """拿一份已有的测速记录,按现在的规则重新判一遍(回算用,不改任何文件)。
541
+
542
+ opening:当作"开赛时预热后的读数"的那一次;不给就用第一批开始时的读数。
543
+ 返回 [{"batch", "change", "drift", "reference", "why", "flagged"}, ...]。"""
544
+ out, seen = [], []
545
+ opening = opening or (rows[0]["before"] if rows else None)
546
+ for r in rows:
547
+ ref, kind = speed_baseline(seen, opening, min_batches)
548
+ change, drift = speed_change(r["before"], r["after"]), speed_change(ref, r["before"])
549
+ why = []
550
+ if change is not None and change > tolerance:
551
+ why.append(f"批内前后相差 {100 * change:.1f}%")
552
+ if drift is not None and drift > tolerance:
553
+ why.append(f"开始时偏离基准 {100 * drift:.1f}%(基准:{kind})")
554
+ if r.get("crowded"):
555
+ why.append(f"逻辑处理器 {r['crowded']} 上有别的任务在跑")
556
+ out.append({"batch": r["batch"], "change": change, "drift": drift, "reference": kind, "why": why, "flagged": bool(why)})
557
+ seen.append(r)
558
+ return out
559
+
560
+
561
+ def speed_change(a, b):
562
+ """两次测速之间的最大相对变化(各类核的实际主频、固定工作量用时,取变化最大的一项)。算不了返回 None。"""
563
+ worst = None
564
+ for label, x in (a or {}).get("by_class", {}).items():
565
+ y = (b or {}).get("by_class", {}).get(label)
566
+ if not y:
567
+ continue
568
+ for key in ("ghz", "engine_ms"):
569
+ if x[key] > 0 and y[key] > 0:
570
+ d = abs(y[key] / x[key] - 1.0)
571
+ worst = d if worst is None else max(worst, d)
572
+ return worst
573
+
574
+
575
+ def speed_text(m):
576
+ if not m or not m.get("by_class"):
577
+ return "没有测到"
578
+ return (";".join(f"{label} {v['ghz']:.2f} GHz、固定工作量 {v['engine_ms']:.0f} 毫秒" for label, v in m["by_class"].items())
579
+ + (f"(逻辑处理器 {m['crowded']} 上有别的任务在跑)" if m.get("crowded") else ""))
580
+
581
+
582
+ # ====================================================================
583
+ # 编译与顶替
584
+ # ====================================================================
585
+
586
+ def resolve_programs(core, doc, out, quiet=False):
587
+ """在主进程里统一编译。某个程序没有文件或编译不过时依次试替补,并把状态明明白白记下来。
588
+
589
+ 返回 {名字: {"entered", "entered_sha", "status": "正常" | "顶替", "problems": [...],
590
+ "used", "used_sha", "used_label", "exe", "exe_sha"}}。全部候选都不行就停下。"""
591
+ build = Path(doc["build_dir"])
592
+ if not all(ord(ch) < 128 for ch in str(build)):
593
+ raise Refuse(f"编译目录必须是纯英文路径:{build}")
594
+ entries, cache = {}, {}
595
+
596
+ def attempt(path):
597
+ """返回 (可执行文件, 源文件哈希, 问题, 详情)。同一个文件只编一次。"""
598
+ if path not in cache:
599
+ p = Path(path)
600
+ if not p.is_file():
601
+ cache[path] = (None, None, "没有文件", "")
602
+ else:
603
+ digest = sha_file(p)
604
+ if not core.is_source(p):
605
+ cache[path] = (p, digest, None, "")
606
+ else:
607
+ try:
608
+ cache[path] = (core.build_bot(p, "exe", build), digest, None, "")
609
+ except RuntimeError as exc:
610
+ cache[path] = (None, digest, "编译失败", str(exc))
611
+ return cache[path]
612
+
613
+ # 报名的程序先几个一起编(编译器是外部进程,互不影响;同一个目标并行编也不会互相踩,见 core._run_cxx)。
614
+ # 替补用到时再编。
615
+ threads = max(1, int(doc.get("compile_workers", 4)))
616
+ pending = queue.Queue()
617
+ for path in sorted(set(doc["players"].values())):
618
+ pending.put(path)
619
+
620
+ def warm():
621
+ while True:
622
+ try:
623
+ attempt(pending.get_nowait())
624
+ except queue.Empty:
625
+ return
626
+
627
+ pool = [threading.Thread(target=warm, daemon=True) for _ in range(threads)]
628
+ for t in pool:
629
+ t.start()
630
+ for t in pool:
631
+ t.join()
632
+
633
+ fatal = []
634
+ for name in sorted(doc["players"]):
635
+ entered = doc["players"][name]
636
+ chain = [{"path": entered, "label": None}] + doc["substitutes"].get(name, []) + doc["substitutes"].get("*", [])
637
+ e = {"entered": entered, "entered_sha": None, "status": None, "problems": []}
638
+ for k, cand in enumerate(chain):
639
+ exe, digest, problem, detail = attempt(cand["path"])
640
+ if k == 0:
641
+ e["entered_sha"] = digest
642
+ if problem:
643
+ e["problems"].append({"path": cand["path"], "sha256": digest, "problem": problem,
644
+ "label": cand["label"] or "报名的程序"})
645
+ if detail:
646
+ log = out / BUILDLOG / f"{core.safe_name(name)}-{len(e['problems'])}.txt"
647
+ log.parent.mkdir(parents=True, exist_ok=True)
648
+ log.write_text(f"{name}:{cand['path']}\n\n{detail}\n", encoding="utf-8")
649
+ e["problems"][-1]["log"] = str(log)
650
+ continue
651
+ e.update(status="正常" if k == 0 else "顶替", used=cand["path"], used_sha=digest,
652
+ used_label=cand["label"], exe=str(exe), exe_sha=sha_file(exe))
653
+ break
654
+ if e["status"] is None:
655
+ fatal.append(f"{name}:" + ";".join(f"{p['label']} {p['problem']}({p['path']})" for p in e["problems"]))
656
+ entries[name] = e
657
+ if not quiet and e["status"] == "顶替":
658
+ why = ";".join(f"{p['label']}{p['problem']}" for p in e["problems"])
659
+ print(f" 顶替:{name} —— {why},由 {e['used_label']} 上场", flush=True)
660
+ if fatal:
661
+ raise Refuse("下面的程序连替补都上不了场,比赛没有开始:\n " + "\n ".join(fatal)
662
+ + f"\n编译器的输出在 {out / BUILDLOG}。修好(或者在配置的 substitutes 里给出能编译的替补)后重新运行。")
663
+ return entries
664
+
665
+
666
+ FROZEN_KEYS = ("entered_sha", "status", "used_sha")
667
+
668
+
669
+ def check_frozen(manifest, doc, kit, compiler, entries):
670
+ """续跑前核对:赛程、选手包、编译器、每个程序。返回不一致的说明列表(空 = 一致)和只需提醒的列表。"""
671
+ frozen = manifest["frozen"]
672
+ bad, notes = [], []
673
+ if frozen["schedule_sha"] != doc["games_sha"]:
674
+ bad.append("赛程变了(赛程.json 里的对局和第一次运行时不同)")
675
+ for name, digest in frozen["kit"].items():
676
+ if kit.get(name) != digest:
677
+ bad.append(f"选手包里的 {name} 变了(引擎或裁判和已经打完的那些局不是同一份)")
678
+ for key, text in (("sha256", "编译器换了"), ("flags", "编译参数变了")):
679
+ if frozen["compiler"][key] != compiler[key]:
680
+ bad.append(f"{text}:原来 {frozen['compiler'][key]},现在 {compiler[key]}")
681
+ for name in sorted(set(frozen["programs"]) | set(entries)):
682
+ old, new = frozen["programs"].get(name), entries.get(name)
683
+ if old is None or new is None:
684
+ bad.append(f"参赛名单变了:{name}")
685
+ continue
686
+ if old["entered_sha"] != new["entered_sha"]:
687
+ bad.append(f"{name} 报名的程序变了:原来 {str(old['entered_sha'])[:12]},现在 {str(new['entered_sha'])[:12]}")
688
+ elif old["status"] != new["status"] or old["used_sha"] != new["used_sha"]:
689
+ bad.append(f"{name} 实际上场的程序变了:原来 {old['status']} {str(old['used_sha'])[:12]},"
690
+ f"现在 {new['status']} {str(new['used_sha'])[:12]}")
691
+ elif old["exe_sha"] != new["exe_sha"]:
692
+ notes.append(f"{name} 的可执行文件重新编译过(文件哈希不同,源码、引擎、编译器都没变)")
693
+ return bad, notes
694
+
695
+
696
+ # ====================================================================
697
+ # run:跑比赛
698
+ # ====================================================================
699
+
700
+ class Runner:
701
+ """管理若干个裁判进程(每个并行位一个),逐局派活。"""
702
+
703
+ def __init__(self, doc, out, entries, plan_, session, on_row):
704
+ self.doc, self.out, self.entries, self.plan = doc, Path(out), entries, plan_
705
+ self.session, self.on_row = session, on_row
706
+ self.core = _kit["core"]
707
+ self.stop = threading.Event()
708
+ self.procs, self.lock = [], threading.Lock()
709
+ self.errors = 0
710
+
711
+ def start_worker(self):
712
+ p = subprocess.Popen([sys.executable, "-X", "utf8", str(HERE / "tournament.py"), "_worker", self.doc["kit"]],
713
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE)
714
+ self.core.adopt(p)
715
+ if self.plan["referee_cpus"]:
716
+ try:
717
+ self.core.set_affinity(p, self.plan["referee_cpus"])
718
+ except Exception:
719
+ pass
720
+ with self.lock:
721
+ self.procs.append(p)
722
+ return p
723
+
724
+ def play_one(self, proc, game, slot):
725
+ """让一个裁判进程打一局。返回 (进程, 结果或 {"error"})。"""
726
+ pin = self.plan["slots"][slot]
727
+ job = {"cmds": [[self.entries[n]["exe"]] for n in game["seats"]], "names": game["seats"],
728
+ "seed": game["map_seed"], "style": game["style"], "affinity": pin["cpus"] if pin else None,
729
+ "replay": str(self.out / REPLAYS / f"{game['id']}.json.gz") if self.doc.get("replays", True) else None}
730
+ timer = None
731
+ try:
732
+ if proc is None or proc.poll() is not None:
733
+ proc = self.start_worker()
734
+ timer = threading.Timer(GAME_TIMEOUT, proc.kill)
735
+ timer.daemon = True
736
+ timer.start()
737
+ proc.stdin.write((json.dumps(job) + "\n").encode("utf-8"))
738
+ proc.stdin.flush()
739
+ raw = proc.stdout.readline()
740
+ if not raw:
741
+ raise RuntimeError(f"裁判进程异常退出(代码 {proc.poll()})")
742
+ return proc, json.loads(raw.decode("utf-8"))
743
+ except Exception as exc:
744
+ self.core.stop_process(proc)
745
+ return None, {"error": f"{type(exc).__name__}: {exc}"}
746
+ finally:
747
+ if timer:
748
+ timer.cancel()
749
+
750
+ def run_batch(self, games, batch):
751
+ todo = queue.Queue()
752
+ for g in games:
753
+ todo.put(g)
754
+ retries = int(self.doc.get("retries", 2))
755
+
756
+ def work(slot):
757
+ proc = None
758
+ while not self.stop.is_set():
759
+ try:
760
+ g = todo.get_nowait()
761
+ except queue.Empty:
762
+ break
763
+ answer = None
764
+ for attempt in range(retries + 1):
765
+ began = time.time()
766
+ proc, answer = self.play_one(proc, g, slot)
767
+ if "error" not in answer or self.stop.is_set():
768
+ break
769
+ if self.stop.is_set() and (answer is None or "error" in answer):
770
+ break
771
+ pin = self.plan["slots"][slot]
772
+ if "error" in answer:
773
+ with self.lock:
774
+ self.errors += 1
775
+ append_line(self.out / ERRORS, {"id": g["id"], "seats": g["seats"], "map_seed": g["map_seed"],
776
+ "time": stamp(), "attempts": retries + 1, "error": answer["error"][:500]})
777
+ continue
778
+ row = {"id": g["id"], "map": g["map"], "map_seed": g["map_seed"], "style": g["style"],
779
+ "seats": g["seats"], **answer, "batch": batch, "session": self.session, "slot": slot,
780
+ "cpus": pin["cpus"] if pin else None, "time": stamp(began)}
781
+ self.on_row(row)
782
+ if proc is not None:
783
+ try:
784
+ proc.stdin.close()
785
+ proc.wait(timeout=5)
786
+ except Exception:
787
+ self.core.stop_process(proc)
788
+
789
+ threads = [threading.Thread(target=work, args=(k,), daemon=True) for k in range(len(self.plan["slots"]))]
790
+ try:
791
+ for t in threads:
792
+ t.start()
793
+ while any(t.is_alive() for t in threads):
794
+ for t in threads:
795
+ t.join(timeout=0.2)
796
+ except KeyboardInterrupt:
797
+ self.close()
798
+ raise
799
+
800
+ def close(self):
801
+ self.stop.set()
802
+ with self.lock:
803
+ for p in self.procs: # 只结束自己起的进程;它们起的裁判核心和 bot 在同一个作业对象里,会跟着结束
804
+ self.core.stop_process(p)
805
+
806
+
807
+ def worker_main(kit):
808
+ """一个并行位:从标准输入逐行读任务,打完一局回一行。"""
809
+ core, engine = load_kit(kit)
810
+ out = sys.stdout.buffer
811
+ try:
812
+ for raw in sys.stdin.buffer:
813
+ job = json.loads(raw.decode("utf-8"))
814
+ began = time.perf_counter()
815
+ try:
816
+ result, replay = core.run_match(engine.Game, job["cmds"], job["names"], job["seed"],
817
+ record=bool(job.get("replay")), affinity=job.get("affinity"),
818
+ style=job["style"])
819
+ if job.get("replay"):
820
+ core.save_replay(replay, job["replay"])
821
+ answer = {"winner": result["winner"], "score": result["score"], "turns": result["turns"],
822
+ "reason": result["reason"], "sig": result["sig"],
823
+ "base_hp": result["base_hp"], "kills": result["kills"],
824
+ "bots": [{"dead": b["dead"], "timeouts": b["timeouts"], "illegal": b["illegal"],
825
+ "first_ms": b["first_ms"], "avg_ms": b["avg_ms"], "max_ms": b["max_ms"]}
826
+ for b in result["bots"]],
827
+ "seconds": round(time.perf_counter() - began, 3)}
828
+ except Exception as exc: # 裁判这一侧的错:报给主进程,这一局不算数
829
+ answer = {"error": f"{type(exc).__name__}: {exc}"}
830
+ out.write((json.dumps(answer, ensure_ascii=False) + "\n").encode("utf-8"))
831
+ out.flush()
832
+ except KeyboardInterrupt:
833
+ pass
834
+ finally:
835
+ engine.shutdown()
836
+
837
+
838
+ def load_event(out):
839
+ out = Path(out)
840
+ if not (out / SCHEDULE).is_file():
841
+ raise Refuse(f"这里没有赛程:{out}(先 plan)")
842
+ doc = read_json(out / SCHEDULE)
843
+ if schedule.games_hash(doc["games"]) != doc["games_sha"]:
844
+ raise Refuse(f"{out / SCHEDULE} 里的对局和它自己记的指纹对不上,赛程被改过。")
845
+ return out, doc
846
+
847
+
848
+ def void_flagged(out, doc, quiet=False):
849
+ """把测速标记过的批次的结果挪进 作废.jsonl,让它们重打。返回作废的局数。"""
850
+ flagged = {r["batch"] for r in read_lines(out / SPEED) if r.get("flagged") and not r.get("voided")}
851
+ if not flagged:
852
+ return 0
853
+ rows = read_lines(out / RESULTS)
854
+ keep = [r for r in rows if r["batch"] not in flagged]
855
+ gone = [r for r in rows if r["batch"] in flagged]
856
+ for r in gone:
857
+ append_line(out / VOID, {**r, "voided": stamp(), "why": "所在批次测速前后不一致"})
858
+ replay = out / REPLAYS / f"{r['id']}.json.gz"
859
+ if replay.is_file():
860
+ (out / REPLAYS / "作废").mkdir(parents=True, exist_ok=True)
861
+ os.replace(replay, out / REPLAYS / "作废" / f"{r['id']}-{int(time.time())}.json.gz")
862
+ tmp = out / f"tmp-{os.getpid()}-{RESULTS}"
863
+ tmp.write_bytes(b"".join((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8") for r in keep))
864
+ os.replace(tmp, out / RESULTS)
865
+ speed = read_lines(out / SPEED)
866
+ for r in speed:
867
+ if r.get("flagged") and r["batch"] in flagged:
868
+ r["voided"] = stamp()
869
+ tmp = out / f"tmp-{os.getpid()}-{SPEED}"
870
+ tmp.write_bytes(b"".join((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8") for r in speed))
871
+ os.replace(tmp, out / SPEED)
872
+ if not quiet:
873
+ print(f"已作废 {len(gone)} 局(批次 {sorted(flagged)}),接下来重打。", flush=True)
874
+ return len(gone)
875
+
876
+
877
+ def run(out, workers=0, redo_flagged=False, quiet=False):
878
+ """跑比赛。可以随时中断;再次运行会核对哈希后接着跑。返回摘要字典。"""
879
+ out, doc = load_event(out)
880
+ core, engine = load_kit(doc["kit"], doc.get("compiler"))
881
+ say = (lambda *a: None) if quiet else (lambda *a: print(*a, flush=True))
882
+ if redo_flagged:
883
+ void_flagged(out, doc, quiet)
884
+ done = {r["id"] for r in read_lines(out / RESULTS)}
885
+ todo = [g for g in doc["games"] if g["id"] not in done]
886
+ title = doc.get("title") or out.name
887
+ say(f"【{title}】共 {len(doc['games'])} 局,已完成 {len(done)},本次要打 {len(todo)}。")
888
+
889
+ # —— 统一编译,核对 ——
890
+ began = time.time()
891
+ inf = engine.info()
892
+ if any(g["style"] >= len(inf["styles"]) for g in doc["games"]):
893
+ raise Refuse(f"赛程里的地图风格编号超出了引擎的 {len(inf['styles'])} 种")
894
+ compiler = compiler_info(core)
895
+ say(f"编译器 {compiler['version']},参数 {compiler['flags']};引擎 {inf['engine']}。统一编译 {len(doc['players'])} 个程序……")
896
+ entries = resolve_programs(core, doc, out, quiet)
897
+ kit = kit_hashes(doc["kit"])
898
+ bad = check_borrow(doc, kit, compiler, entries)
899
+ if bad:
900
+ raise Refuse(f"这场比赛借用了 {doc['borrow']['from']} 的结果,但现在这一套和那场比赛的不一样,没有开打:\n - "
901
+ + "\n - ".join(bad))
902
+ if doc.get("borrow"):
903
+ borrowed_rows(out, doc)
904
+ say(f" 借用的 {doc['borrow']['rows']} 局已核对:引擎和裁判的文件、编译器、{len(doc['borrow']['names'])} 个程序的源码都和那场比赛一致。")
905
+ manifest_path = out / MANIFEST
906
+ if manifest_path.is_file():
907
+ manifest = read_json(manifest_path)
908
+ bad, notes = check_frozen(manifest, doc, kit, compiler, entries)
909
+ if bad:
910
+ raise Refuse(f"和这场比赛第一次运行时冻结的状态不一致,不能接着跑({manifest_path}):\n - " + "\n - ".join(bad)
911
+ + "\n已经打完的局是用原来那一套打的。把文件恢复原样后再运行;"
912
+ "如果确实要用新的一套,换一个赛事目录重新 plan。")
913
+ for n in notes:
914
+ say(" 提醒:" + n)
915
+ manifest["frozen"]["programs"] = {n: {**manifest["frozen"]["programs"][n], "exe": e["exe"], "exe_sha": e["exe_sha"]}
916
+ for n, e in entries.items()}
917
+ else:
918
+ if done:
919
+ raise Refuse(f"{out} 里已经有结果,却没有 {MANIFEST},没法核对这些结果是用什么打的。")
920
+ manifest = {"title": title, "created": stamp(),
921
+ "frozen": {"schedule_sha": doc["games_sha"], "kit": kit, "compiler": compiler, "programs": entries},
922
+ "engine": inf["engine"], "budget": {k: inf[k] for k in ("budget_kind", "first_ms", "turn_ms",
923
+ "wall_first_ms", "wall_turn_ms",
924
+ "units_per_ms", "units_source")},
925
+ "config": {k: v for k, v in doc.items() if k not in ("games", "audit")},
926
+ "topology": core.cpu_topology(), "sessions": [], "speed_reference": None}
927
+ plan_ = pin_plan(core, doc, workers)
928
+ say("绑核:" + describe_plan(plan_) + ("\n " + plan_["note"] if plan_["note"] else ""))
929
+ session = len(manifest["sessions"]) + 1
930
+ for s in manifest["sessions"]:
931
+ if not s.get("end") and not s.get("killed"):
932
+ # 上一次运行被强行结束,没来得及写收尾:按结果文件把它打了几局、打到什么时候补上。
933
+ mine = [r for r in read_lines(out / RESULTS) if r["session"] == s["session"]]
934
+ last = max((time.mktime(time.strptime(r["time"], "%Y-%m-%d %H:%M:%S")) + r["seconds"] for r in mine),
935
+ default=s["start_epoch"])
936
+ s.update(killed=True, interrupted=True, played=len(mine), seconds=round(max(0.0, last - s["start_epoch"]), 1))
937
+ say(f" 提醒:第 {s['session']} 次运行没有正常结束(被强行中断)。它打完的 {len(mine)} 局都在,结果不受影响。")
938
+ record = {"session": session, "start": stamp(), "start_epoch": round(time.time(), 1), "end": None,
939
+ "plan": plan_, "compile_seconds": round(time.time() - began, 1), "played": 0, "errors": 0,
940
+ "interrupted": False, "python": sys.version.split()[0]}
941
+ manifest["sessions"].append(record)
942
+ write_json(manifest_path, manifest)
943
+ summary = {"out": str(out), "entries": entries, "played": 0, "errors": 0, "flagged": [], "interrupted": False,
944
+ "remaining": len(todo)}
945
+ if not todo:
946
+ record.update(end=stamp(), end_epoch=round(time.time(), 1))
947
+ write_json(manifest_path, manifest)
948
+ say("没有要打的局。")
949
+ return summary
950
+
951
+ # —— 逐批:测速 → 打 → 测速 ——
952
+ speed_cfg = {"enabled": True, "tolerance": 0.10, "repeat": 3, "warmup_seconds": 15, "reference_batches": 3,
953
+ **(doc.get("speed") or {})}
954
+ per_batch = max(1, int(doc.get("batch_maps", 1)))
955
+ batches = {}
956
+ for g in todo:
957
+ batches.setdefault(g["map"] // per_batch, []).append(g)
958
+ lock = threading.Lock()
959
+ started, total = time.time(), len(todo)
960
+ last_print = [time.time()]
961
+
962
+ def on_row(row):
963
+ with lock:
964
+ append_line(out / RESULTS, row)
965
+ record["played"] += 1
966
+ n = record["played"]
967
+ now = time.time()
968
+ if now - last_print[0] >= 15 or n == total:
969
+ last_print[0] = now
970
+ left = (total - n) * (now - started) / n
971
+ say(f" {len(done) + n}/{len(doc['games'])} 局 已用 {(now - started) / 60:.1f} 分钟,约剩 {left / 60:.1f} 分钟")
972
+
973
+ runner = Runner(doc, out, entries, plan_, session, on_row)
974
+ try:
975
+ if speed_cfg["enabled"]:
976
+ # 预热之后再取这次运行的第一个读数(冷机时的读数偏快,不能当基准)。
977
+ record["warmup"] = warm_up(core, plan_, float(speed_cfg["warmup_seconds"]), speed_cfg["repeat"])
978
+ opening = measure_speed(core, plan_, speed_cfg["repeat"])
979
+ record["speed_opening"] = opening
980
+ if manifest["speed_reference"] is None:
981
+ manifest["speed_reference"] = opening
982
+ write_json(manifest_path, manifest)
983
+ say(f"预热 {record['warmup']['seconds']:.0f} 秒后测速:{speed_text(opening)}")
984
+ if opening.get("crowded"):
985
+ say(f" !bot 要用的逻辑处理器 {opening['crowded']} 上现在有别的任务在跑。正式比赛应当先把它们停掉;"
986
+ "继续跑的话这些批次会被标记。")
987
+ for batch in sorted(batches):
988
+ games = batches[batch]
989
+ before = measure_speed(core, plan_, speed_cfg["repeat"]) if speed_cfg["enabled"] else None
990
+ reference, ref_kind = speed_baseline(read_lines(out / SPEED), manifest["speed_reference"],
991
+ speed_cfg["reference_batches"])
992
+ t0 = time.time()
993
+ runner.run_batch(games, batch)
994
+ after = measure_speed(core, plan_, speed_cfg["repeat"]) if speed_cfg["enabled"] else None
995
+ change = speed_change(before, after)
996
+ drift = speed_change(reference, before)
997
+ tol = float(speed_cfg["tolerance"])
998
+ crowded = sorted(set((before or {}).get("crowded", [])) | set((after or {}).get("crowded", [])))
999
+ why = []
1000
+ if change is not None and change > tol:
1001
+ why.append(f"批内前后相差 {100 * change:.1f}%")
1002
+ if drift is not None and drift > tol:
1003
+ why.append(f"开始时偏离基准 {100 * drift:.1f}%(基准:{ref_kind})")
1004
+ if crowded:
1005
+ why.append(f"逻辑处理器 {crowded} 上有别的任务在跑")
1006
+ append_line(out / SPEED, {"batch": batch, "session": session, "games": len(games),
1007
+ "seconds": round(time.time() - t0, 1), "before": before, "after": after,
1008
+ "change": None if change is None else round(change, 4),
1009
+ "drift_from_reference": None if drift is None else round(drift, 4),
1010
+ "reference": {"kind": ref_kind, "by_class": (reference or {}).get("by_class")},
1011
+ "crowded": crowded, "tolerance": tol, "flagged": bool(why), "why": why})
1012
+ if why:
1013
+ summary["flagged"].append(batch)
1014
+ say(f" !批次 {batch} 被测速标记({';'.join(why)};阈值 {100 * tol:.0f}%):前 {speed_text(before)};后 {speed_text(after)}。"
1015
+ "结果照常保留。")
1016
+ except KeyboardInterrupt:
1017
+ record["interrupted"] = summary["interrupted"] = True
1018
+ say("\n已中断,自己起的进程已清理。打完的局都已保存,再次运行会接着跑。")
1019
+ finally:
1020
+ runner.close()
1021
+ record.update(end=stamp(), end_epoch=round(time.time(), 1), errors=runner.errors,
1022
+ seconds=round(time.time() - started, 1))
1023
+ write_json(manifest_path, manifest)
1024
+ left = len(doc["games"]) - len(done) - record["played"]
1025
+ summary.update(played=record["played"], errors=runner.errors, remaining=left, seconds=record["seconds"])
1026
+ if not summary["interrupted"]:
1027
+ say(f"本次打了 {record['played']} 局,用时 {record['seconds'] / 60:.1f} 分钟"
1028
+ + (f";有 {runner.errors} 局裁判这一侧出错没有结果(见 {ERRORS}),再运行一次会重打" if runner.errors else "")
1029
+ + (f";还剩 {left} 局" if left else ",全部打完。"))
1030
+ return summary
1031
+
1032
+
1033
+ # ====================================================================
1034
+ # rate:评分
1035
+ # ====================================================================
1036
+
1037
+ REASONS = {"base": "基地被毁", "hp": "到回合上限比基地血量", "kills": "到回合上限比击毁数", "stars": "到回合上限比吃星数",
1038
+ "damage": "到回合上限比基地伤害", "draw": "到回合上限平局", "fault": "故障判负"}
1039
+
1040
+
1041
+ def reason_text(reason):
1042
+ if reason.startswith("both_"):
1043
+ return "两个基地同回合被毁"
1044
+ return REASONS.get(reason, reason)
1045
+
1046
+
1047
+ def bt_rows(rows, key=None):
1048
+ """结果行 → bt.py 要的格式。key 给了就用 (key, 图号) 作地图标识(几场比赛合并时用)。"""
1049
+ return [{"map": r["map"] if key is None else (key, r["map"]), "a": r["seats"][0], "b": r["seats"][1],
1050
+ "score": r["score"][0]} for r in rows]
1051
+
1052
+
1053
+ def collect_stats(rows, names):
1054
+ """每个程序的胜平负、故障、用量,以及两两之间的胜平负矩阵。"""
1055
+ stats = {n: {"games": 0, "w": 0, "d": 0, "l": 0, "forfeits": 0, "faulty_games": 0, "timeout_turns": 0,
1056
+ "illegal_turns": 0, "turns": 0, "max_ms": 0.0, "avg_ms_sum": 0.0, "first_ms_max": 0.0} for n in names}
1057
+ matrix = {a: {b: [0, 0, 0] for b in names if b != a} for a in names}
1058
+ for r in rows:
1059
+ for seat, n in enumerate(r["seats"]):
1060
+ other = r["seats"][1 - seat]
1061
+ s, b = stats[n], r["bots"][seat]
1062
+ k = 1 if r["winner"] < 0 else (0 if r["winner"] == seat else 2)
1063
+ s["games"] += 1
1064
+ s["wdl"[k]] += 1
1065
+ if n != other:
1066
+ matrix[n][other][k] += 1
1067
+ s["forfeits"] += bool(b["dead"])
1068
+ s["faulty_games"] += bool(b["dead"] or b["timeouts"] or b["illegal"])
1069
+ s["timeout_turns"] += b["timeouts"]
1070
+ s["illegal_turns"] += b["illegal"]
1071
+ s["turns"] += r["turns"]
1072
+ s["max_ms"] = max(s["max_ms"], b["max_ms"])
1073
+ s["first_ms_max"] = max(s["first_ms_max"], b["first_ms"])
1074
+ s["avg_ms_sum"] += b["avg_ms"]
1075
+ for s in stats.values():
1076
+ g = s["games"]
1077
+ s["avg_turns"] = round(s.pop("turns") / g, 1) if g else None
1078
+ s["avg_ms"] = round(s.pop("avg_ms_sum") / g, 3) if g else None
1079
+ s["score_rate"] = round((s["w"] + 0.5 * s["d"]) / g, 4) if g else None
1080
+ return stats, matrix
1081
+
1082
+
1083
+ def show_name(name, labels, display=None):
1084
+ """成绩表里怎么写一个程序:有显示名的是“显示名(名字)”,再带上说明(基准、出题方参照)。"""
1085
+ shown = (display or {}).get(name)
1086
+ text = f"{shown}({name})" if shown and shown != name else name
1087
+ return f"{text}({labels[name]})" if labels.get(name) else text
1088
+
1089
+
1090
+ def display_of(data):
1091
+ return {n: p.get("display") for n, p in data["players"].items()}
1092
+
1093
+
1094
+ def fmt(v, digits=0):
1095
+ if v is None:
1096
+ return "—"
1097
+ v = round(v, digits)
1098
+ return f"{v + 0.0 if v else 0.0:.{digits}f}" # 不显示 "-0"
1099
+
1100
+
1101
+ def rate(out, boot=None, quiet=False, write=True, only=None, unranked=None, labels=None, display=None, title=None):
1102
+ """评分。返回成绩字典(也就是 成绩.json 的内容)。
1103
+
1104
+ only:只看这些程序(它们之间的对局),别的程序和有它们参加的对局都不算——"某位选手的视角"用它。
1105
+ 这时通常同时给 unranked / labels / display / title 来盖掉赛程里的设定,并且 write=False(不覆盖整场的成绩文件)。"""
1106
+ out, doc = load_event(out)
1107
+ own_rows = read_lines(out / RESULTS)
1108
+ rows = own_rows + borrowed_rows(out, doc)
1109
+ names = sorted(doc["players"])
1110
+ if only is not None:
1111
+ unknown = sorted(set(only) - set(names))
1112
+ if unknown:
1113
+ raise Refuse(f"only 里的 {unknown} 不在参赛名单里")
1114
+ names = sorted(set(only))
1115
+ rows = [r for r in rows if set(r["seats"]) <= set(names)]
1116
+ anchor = doc.get("anchor")
1117
+ if anchor is None:
1118
+ raise Refuse("这场比赛没有指定基准(anchor),定不了零点。")
1119
+ if not rows:
1120
+ raise Refuse(f"{out} 里还没有结果。")
1121
+ labels = (doc.get("labels") or {}) if labels is None else labels
1122
+ display = (doc.get("display") or {}) if display is None else display
1123
+ unranked = set((doc.get("unranked") or []) if unranked is None else unranked) | {anchor}
1124
+ boot = int(boot if boot is not None else doc.get("boot", 1000))
1125
+ def ordered(r):
1126
+ order_ = sorted(names, key=lambda n: (-(r[n]["rating"] if r[n]["rating"] is not None else -1e9), n))
1127
+ return order_, [n for n in order_ if n not in unranked and r[n]["rating"] is not None]
1128
+
1129
+ try:
1130
+ # 先只拟合一次定出名次,再重抽样:相邻两名之差的区间要在每次重抽样里直接算,所以得先知道谁和谁相邻。
1131
+ point, _ = bt.rate(bt_rows(rows), names, anchor, boot=0)
1132
+ _, ranked = ordered(point)
1133
+ adjacent = list(zip(ranked, ranked[1:]))
1134
+ ratings, boots, diffs = bt.rate(bt_rows(rows), names, anchor, boot=boot, diffs=adjacent)
1135
+ except ValueError as exc:
1136
+ raise Refuse(f"评分算不了:{exc}")
1137
+ stats, matrix = collect_stats(rows, names)
1138
+ order, ranked = ordered(ratings)
1139
+ groups = bt.tie_groups_by_diff(ranked, diffs)
1140
+ rank, tie = {}, {}
1141
+ for g in groups:
1142
+ first = len(rank) + 1
1143
+ for n in g:
1144
+ rank[n] = len(rank) + 1
1145
+ tie[n] = [first, first + len(g) - 1] if len(g) > 1 else None
1146
+ manifest = read_json(out / MANIFEST) if (out / MANIFEST).is_file() else None
1147
+ programs = manifest["frozen"]["programs"] if manifest else {}
1148
+ speed = read_lines(out / SPEED)
1149
+ flagged = sorted({r["batch"] for r in speed if r.get("flagged") and not r.get("voided")})
1150
+ errors = read_lines(out / ERRORS)
1151
+ done_ids = {r["id"] for r in own_rows} # 借来的结果用的是那场比赛的局号,不能拿来对本场的赛程
1152
+ scheduled = [g for g in doc["games"] if only is None or set(g["seats"]) <= set(names)]
1153
+ missing = [g["id"] for g in scheduled if g["id"] not in done_ids]
1154
+ n_borrowed = sum(1 for r in rows if r.get("borrowed_from"))
1155
+ reasons = {}
1156
+ for r in rows:
1157
+ reasons[r["reason"]] = reasons.get(r["reason"], 0) + 1
1158
+ draws = sum(1 for r in rows if r["winner"] < 0)
1159
+ seat0 = sum(1 for r in rows if r["winner"] == 0)
1160
+ players = {}
1161
+ for n in names:
1162
+ e = programs.get(n, {})
1163
+ players[n] = {"rank": rank.get(n), "tie": tie.get(n), **ratings[n], **stats[n],
1164
+ "label": labels.get(n), "display": display.get(n), "ranked": n not in unranked,
1165
+ "status": e.get("status"), "used_label": e.get("used_label"),
1166
+ "problems": [p["problem"] for p in e.get("problems", [])]}
1167
+ data = {"title": title or doc.get("title") or out.name, "rated": stamp(), "anchor": anchor, "names": names, "order": order,
1168
+ "games": len(rows), "expected_games": len(scheduled) + n_borrowed, "complete": not missing,
1169
+ "borrowed_games": n_borrowed, "borrowed_from": (doc.get("borrow") or {}).get("from") if n_borrowed else None,
1170
+ "all_pairs": all(sum(c) > 0 for row in matrix.values() for c in row.values()),
1171
+ "maps": len({r["map"] for r in rows}), "boot": boots, "players": players, "matrix": matrix,
1172
+ "adjacent": [{"high": a, "low": b, "diff": diffs[a, b]["diff"], "diff_low": diffs[a, b]["low"],
1173
+ "diff_high": diffs[a, b]["high"],
1174
+ "tied": diffs[a, b]["low"] is not None and diffs[a, b]["low"] <= 0.0} for a, b in adjacent],
1175
+ "overall": {"avg_turns": round(sum(r["turns"] for r in rows) / len(rows), 1),
1176
+ "draw_rate": round(draws / len(rows), 4), "draws": draws,
1177
+ "seat0_wins": seat0, "seat1_wins": len(rows) - draws - seat0,
1178
+ "fault_games": sum(1 for r in rows if r["reason"] == "fault"), "reasons": reasons,
1179
+ "seconds_per_game": round(sum(r["seconds"] for r in rows) / len(rows), 3)},
1180
+ "substitutions": [{"name": n, "used_label": e["used_label"],
1181
+ "why": [f"{p['label']}{p['problem']}" for p in e["problems"]]}
1182
+ for n, e in sorted(programs.items()) if e.get("status") == "顶替" and n in names],
1183
+ "flagged_batches": flagged,
1184
+ "judge_errors": len({e["id"] for e in errors if only is None or set(e["seats"]) <= set(names)} - done_ids)}
1185
+ text = render_scores(data)
1186
+ if write:
1187
+ (out / "成绩.md").write_text(text, encoding="utf-8")
1188
+ write_json(out / "成绩.json", data)
1189
+ if not quiet:
1190
+ print(text)
1191
+ return data
1192
+
1193
+
1194
+ def rank_text(p):
1195
+ if p["rank"] is None:
1196
+ return "—"
1197
+ if p["tie"]:
1198
+ return f"{p['rank']}({p['tie'][0]}–{p['tie'][1]} 并列)"
1199
+ return str(p["rank"])
1200
+
1201
+
1202
+ def ranking_table(data, mark=None):
1203
+ """名次表(Markdown 行的列表)。mark:要加粗的名字。"""
1204
+ lines = ["| 名次 | 选手 | 实力值 | 95% 区间 | 胜 | 平 | 负 | 得分率 | 故障判负局 | 有故障的局 | 平均局长 | 每回合用量:平均 / 最大 |",
1205
+ "|---|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---|"]
1206
+ labels = {n: p["label"] for n, p in data["players"].items()}
1207
+ for n in data["order"]:
1208
+ p = data["players"][n]
1209
+ name = show_name(n, labels, display_of(data)) + ("(由" + str(p["used_label"]) + "顶替)" if p["status"] == "顶替" else "")
1210
+ if n == mark:
1211
+ name = f"**{name}**"
1212
+ ci = "—" if p["low"] is None or n == data["anchor"] else f"{fmt(p['low'])} ~ {fmt(p['high'])}"
1213
+ lines.append(f"| {rank_text(p)} | {name} | {fmt(p['rating'])} | {ci} | {p['w']} | {p['d']} | {p['l']} | "
1214
+ f"{'—' if p['score_rate'] is None else format(100 * p['score_rate'], '.1f') + '%'} | "
1215
+ f"{p['forfeits']} | {p['faulty_games']} | {fmt(p['avg_turns'], 0)} | "
1216
+ f"{fmt(p['avg_ms'], 1)} / {fmt(p['max_ms'], 1)} |")
1217
+ return lines
1218
+
1219
+
1220
+ TIE_RULE = ("并列的判法:把选手按实力值排好,看相邻两名“实力值之差”的 95% 区间(每次重抽样里直接算两人的差)。"
1221
+ "这个区间含 0,说明现有的对局分不出这两位谁强,标为并列;并列可以连成一串。"
1222
+ "表里每位选手自己的区间是他相对基准的实力值的区间,只用来看这个数有多准,不用来判并列:"
1223
+ "两位选手各自的区间有重叠,不等于他们并列。")
1224
+
1225
+
1226
+ def adjacent_table(data):
1227
+ """相邻名次之差(Markdown 行的列表,末尾带一个空行)。旧的成绩文件里没有这一项时返回空列表。"""
1228
+ rows = data.get("adjacent")
1229
+ if not rows:
1230
+ return []
1231
+ labels = {n: p["label"] for n, p in data["players"].items()}
1232
+ lines = ["## 相邻名次之差", "", "| 相邻的两名 | 实力值之差 | 差的 95% 区间 | 判定 |", "|---|---:|---|---|"]
1233
+ for r in rows:
1234
+ a, b = data["players"][r["high"]], data["players"][r["low"]]
1235
+ ci = "算不出(图太少)" if r["diff_low"] is None else f"{fmt(r['diff_low'])} ~ {fmt(r['diff_high'])}"
1236
+ lines.append(f"| 第 {a['rank']} 名 {show_name(r['high'], labels, display_of(data))} − "
1237
+ f"第 {b['rank']} 名 {show_name(r['low'], labels, display_of(data))} | "
1238
+ f"{fmt(r['diff'])} | {ci} | {'并列(区间含 0)' if r['tied'] else '分得开'} |")
1239
+ return lines + [""]
1240
+
1241
+
1242
+ def matrix_table(data):
1243
+ labels = {n: p["label"] for n, p in data["players"].items()}
1244
+ order = data["order"]
1245
+ lines = ["| 行 对 列 | " + " | ".join(order) + " |", "|---|" + "---|" * len(order)]
1246
+ for a in order:
1247
+ cells = []
1248
+ for b in order:
1249
+ if a == b:
1250
+ cells.append("·")
1251
+ else:
1252
+ w, d, l = data["matrix"][a][b]
1253
+ cells.append(f"{w}-{d}-{l}" if w + d + l else "")
1254
+ lines.append(f"| {show_name(a, labels, display_of(data))} | " + " | ".join(cells) + " |")
1255
+ return lines
1256
+
1257
+
1258
+ def render_scores(data):
1259
+ o = data["overall"]
1260
+ pairing = ("每张图上每一对换边各打一局" if data.get("all_pairs", True)
1261
+ else "每张图上要交手的每一对换边各打一局;不是全配对,没有交手的在下面的两两表里是空格")
1262
+ lines = [f"# {data['title']} · 成绩", "",
1263
+ f"{data['games']} 局({data['maps']} 张图,{pairing})。"
1264
+ + (f"其中 {data['borrowed_games']} 局不是这次打的,借用自 {data['borrowed_from']}(同样的图、同样的程序)。"
1265
+ if data.get("borrowed_games") else "")
1266
+ + f"Bradley–Terry 拟合:胜 1、平 0.5,每对加一局虚拟平局;{data['anchor']} = 0,强 10 倍 = +400。"
1267
+ f"区间是按地图整块重抽样 {data['boot']} 次的 95% 区间。", "", TIE_RULE, ""]
1268
+ if not data["complete"]:
1269
+ lines += [f"**注意:赛程共 {data['expected_games']} 局,只完成了 {data['games']} 局,以下是中途成绩。**", ""]
1270
+ lines += ranking_table(data)
1271
+ lines += ["", f"全场:平均每局 {o['avg_turns']} 回合,平局率 {100 * o['draw_rate']:.1f}%"
1272
+ f"({o['draws']} 局),0 号位胜 {o['seat0_wins']} 局、1 号位胜 {o['seat1_wins']} 局,"
1273
+ f"因故障结束 {o['fault_games']} 局。结束方式:"
1274
+ + ",".join(f"{reason_text(k)} {v}" for k, v in sorted(o["reasons"].items(), key=lambda kv: -kv[1])) + "。",
1275
+ "", "“故障判负局”是这个程序崩溃、退出或连续 3 次超预算 / 输出不合法而被判负的局数;"
1276
+ "“有故障的局”还包括出现过超预算或不合法输出但没有判负的局。用量的单位是 CPU 毫秒。",
1277
+ ""]
1278
+ lines += adjacent_table(data)
1279
+ lines += ["## 两两胜平负(行的程序 对 列的程序:胜-平-负)", ""]
1280
+ lines += matrix_table(data)
1281
+ notes = []
1282
+ for s in data["substitutions"]:
1283
+ notes.append(f"- 顶替:{s['name']} —— {';'.join(s['why'])},由 {s['used_label']} 上场。")
1284
+ if data["flagged_batches"]:
1285
+ notes.append(f"- 测速前后不一致的批次:{data['flagged_batches']}(详见 测速.jsonl)。这些批次的结果仍计入。")
1286
+ if data["judge_errors"]:
1287
+ notes.append(f"- 有 {data['judge_errors']} 局因裁判这一侧出错没有结果,不计入。")
1288
+ if notes:
1289
+ lines += ["", "## 需要注意的", ""] + notes
1290
+ return "\n".join(lines) + "\n"
1291
+
1292
+
1293
+ def status(out):
1294
+ out, doc = load_event(out)
1295
+ rows = read_lines(out / RESULTS)
1296
+ print(f"【{doc.get('title') or out.name}】{len(rows)}/{len(doc['games'])} 局")
1297
+ if (out / MANIFEST).is_file():
1298
+ m = read_json(out / MANIFEST)
1299
+ for n, e in sorted(m["frozen"]["programs"].items()):
1300
+ if e["status"] == "顶替":
1301
+ print(f" 顶替:{n} —— " + ";".join(f"{p['label']}{p['problem']}" for p in e["problems"]) + f",由 {e['used_label']} 上场")
1302
+ for s in m["sessions"]:
1303
+ print(f" 第 {s['session']} 次运行:{s['start']} → {s['end'] or '没有正常结束'},打了 {s['played']} 局"
1304
+ + (",被中断" if s["interrupted"] else "") + f",{describe_plan(s['plan'])}")
1305
+ for r in read_lines(out / SPEED):
1306
+ if r.get("flagged"):
1307
+ print(f" 测速标记:批次 {r['batch']}({';'.join(r.get('why') or [])})" + (",已作废重打" if r.get("voided") else ""))
1308
+ errors = read_lines(out / ERRORS)
1309
+ if errors:
1310
+ print(f" 裁判错误记录 {len(errors)} 条,最后一条:{errors[-1]['error'][:120]}")
1311
+
1312
+
1313
+ # ====================================================================
1314
+ # estimate:耗时估算
1315
+ # ====================================================================
1316
+
1317
+ # 实测常数:彩排三场比赛 928 局的拟合(主办方/彩排数据分析.md 第二部分;省电状态、只用大核同时 4 局)。
1318
+ # 一局的墙上时间 ≈ 0.02 秒 + 首回合用量 + 回合数 ×(慢的一方的每回合平均用量 + 1.0 毫秒),残差的标准差 0.026 秒。
1319
+ OVERHEAD_GAME = 0.02 # 每局固定开销(秒):起两个 bot 进程、收尾、存回放
1320
+ OVERHEAD_TURN = 0.001 # 每回合裁判自己的开销(秒)。彩排之前按 3 毫秒估,把彩排的总用时高估了 28%
1321
+ FULL_BUDGET_MS = 52.5 # "用满 50"按 52.5 算:用量很高时每回合的额外开销更大(复核时量过 45.1 → 墙上 47.9),多留 5%
1322
+ SPEED_TEST = 0.7 # 每批前后各一次测速(秒)
1323
+ REHEARSAL_TURNS = 53.4 # 彩排实测的平均局长(回合);92% 的局不超过 70 回合,打满 300 回合的占 0.86%
1324
+ REHEARSAL_P99_TURNS = 136 # 彩排局长的 99% 分位:估"批尾等最长的一局"用
1325
+ WORST_TURNS = 70 # "最坏"口径用的平均局长
1326
+
1327
+
1328
+ def game_seconds(turns, turn_ms, first_ms):
1329
+ """一局的墙上时间:两个 bot 同时想,所以每回合等的是较慢的一方。"""
1330
+ return OVERHEAD_GAME + first_ms / 1000.0 + turns * (turn_ms / 1000.0 + OVERHEAD_TURN)
1331
+
1332
+
1333
+ def estimate_seconds(pairs, maps, slots, per_game, batch_maps=1, longest_game=0.0):
1334
+ """总用时 = 各局用时之和 ÷ 并行数 + 每批(前后两次测速 + 批尾等最后几局时别的并行位空着,约半局最长的)。
1335
+
1336
+ 用它回算彩排的三场比赛是 110、107、87 秒,实际 114、105、84 秒。"""
1337
+ games = pairs * maps * 2
1338
+ batches = math.ceil(maps / batch_maps)
1339
+ return math.ceil(games / slots) * per_game + batches * (2 * SPEED_TEST + 0.5 * longest_game), games
1340
+
1341
+
1342
+ def estimate_cases(turns=WORST_TURNS):
1343
+ """(栏目名, 一局的秒数, 批内最长的一局的秒数)。前三栏是"彩排实测局长"口径,后两栏是"最坏"口径。"""
1344
+ real = REHEARSAL_TURNS
1345
+ cases = []
1346
+ for label, ms, first in ((f"实测局长 {real:g} 回合,每回合约 5(像彩排里用得少的选手)", 5.0, 15.0),
1347
+ (f"实测局长 {real:g} 回合,每回合约 25(像彩排里用得多的选手)", 25.0, 35.0),
1348
+ (f"实测局长 {real:g} 回合,全部用满 50", FULL_BUDGET_MS, 60.0)):
1349
+ cases.append((label, game_seconds(real, ms, first), game_seconds(REHEARSAL_P99_TURNS, ms, first)))
1350
+ for label, first in ((f"最坏:平均 {turns:g} 回合,全部用满 50", 60.0),
1351
+ (f"最坏,且首回合也用满 2000", 2000.0)):
1352
+ cases.append((label, game_seconds(turns, FULL_BUDGET_MS, first), game_seconds(300, FULL_BUDGET_MS, first)))
1353
+ return cases
1354
+
1355
+
1356
+ def estimate(programs=14, slots=4, turns=WORST_TURNS, max_n=40, pairs=None):
1357
+ head = f"{programs} 个程序全配对," if pairs is None else ""
1358
+ pairs = pairs if pairs is not None else programs * (programs - 1) // 2
1359
+ cases = estimate_cases(turns)
1360
+ print(f"{head}{pairs} 对;同时 {slots} 局;每张图 {pairs * 2} 局。单位:分钟。")
1361
+ print("两种口径:“实测局长”按彩排实测的平均局长算(三栏是选手每回合用量的三种情况);“最坏”按更长的局、全部用满预算算。"
1362
+ "式子是彩排 928 局拟合出来的(每回合的额外开销约 1 毫秒)。按每一对都是选手的用量算,没有单独扣掉基准、陪练那几对,略偏保守。")
1363
+ print("| N(每对的图数) | 总局数 | " + " | ".join(c[0] for c in cases) + " |")
1364
+ print("|---:|---:|" + "---:|" * len(cases))
1365
+ for n in [x for x in (1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 36, 40, 50, 60, 64, 80, 100) if x <= max_n]:
1366
+ cells = []
1367
+ for _, per, longest in cases:
1368
+ sec, games = estimate_seconds(pairs, n, slots, per, longest_game=longest)
1369
+ cells.append(f"{sec / 60:.1f}")
1370
+ print(f"| {n} | {games} | " + " | ".join(cells) + " |")
1371
+ return cases
1372
+
1373
+
1374
+ # ====================================================================
1375
+
1376
+ def main():
1377
+ setup_console()
1378
+ if sys.argv[1:2] == ["_worker"]:
1379
+ return worker_main(sys.argv[2])
1380
+ ap = argparse.ArgumentParser(description="坦克大战正式赛", formatter_class=argparse.RawDescriptionHelpFormatter,
1381
+ epilog=__doc__)
1382
+ sub = ap.add_subparsers(dest="cmd", required=True)
1383
+ p = sub.add_parser("plan", help="冻结赛程")
1384
+ p.add_argument("config")
1385
+ p = sub.add_parser("run", help="跑比赛(可中断续跑)")
1386
+ p.add_argument("out")
1387
+ p.add_argument("--workers", type=int, default=0, help="同时打几局(默认按配置;0 = 绑核方案的全部位置)")
1388
+ p.add_argument("--redo-flagged", action="store_true", help="先把测速标记过的批次作废,再重打")
1389
+ p = sub.add_parser("rate", help="评分")
1390
+ p.add_argument("out")
1391
+ p.add_argument("--boot", type=int, default=None)
1392
+ p = sub.add_parser("status", help="进度")
1393
+ p.add_argument("out")
1394
+ p = sub.add_parser("cpus", help="看绑核方案")
1395
+ p.add_argument("config")
1396
+ p = sub.add_parser("estimate", help="N 与耗时的对照表")
1397
+ p.add_argument("--programs", type=int, default=14)
1398
+ p.add_argument("--pairs", type=int, default=None, help="直接给对数(不是全配对时用)")
1399
+ p.add_argument("--slots", type=int, default=4, help="同时打几局")
1400
+ p.add_argument("--turns", type=int, default=WORST_TURNS, help="“最坏”口径用的平均局长")
1401
+ p.add_argument("--max-n", type=int, default=64)
1402
+ a = ap.parse_args()
1403
+ if a.cmd == "plan":
1404
+ plan(read_json(a.config), Path(a.config).resolve().parent)
1405
+ elif a.cmd == "run":
1406
+ s = run(a.out, a.workers, a.redo_flagged)
1407
+ if s["interrupted"]:
1408
+ raise SystemExit(130)
1409
+ if s["remaining"]:
1410
+ raise SystemExit(2)
1411
+ elif a.cmd == "rate":
1412
+ rate(a.out, a.boot)
1413
+ elif a.cmd == "status":
1414
+ status(a.out)
1415
+ elif a.cmd == "cpus":
1416
+ cfg = resolve_config(read_json(a.config), Path(a.config).resolve().parent)
1417
+ core, _ = load_kit(cfg["kit"], cfg.get("compiler"))
1418
+ for k in core.cpu_topology():
1419
+ print(f" 物理核 {k['core']:2d} 效率等级 {k['class']} 逻辑处理器 {k['cpus']}")
1420
+ print(describe_plan(pin_plan(core, cfg)))
1421
+ else:
1422
+ estimate(a.programs, a.slots, a.turns, a.max_n, a.pairs)
1423
+
1424
+
1425
+ if __name__ == "__main__":
1426
+ main()