dsh-eval-guard 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -13
- package/assets/commit0/instance.json +30 -0
- package/assets/commit0/workspace.zip +0 -0
- package/assets/featurebench/hidden/test_regression.py +690 -0
- package/assets/featurebench/instance.json +16 -0
- package/assets/featurebench/workspace.zip +0 -0
- package/assets/swe-bench-pro/hidden/test.patch +36 -0
- package/assets/swe-bench-pro/instance.json +10 -0
- package/assets/swe-bench-pro/workspace.zip +0 -0
- package/eval/README.md +18 -0
- package/eval/arena/arena/README.md +21 -0
- package/eval/arena/arena/bt.py +104 -0
- package/eval/arena/arena/clock.py +15 -0
- package/eval/arena/arena/core.py +265 -0
- package/eval/arena/arena/run.cmd +24 -0
- package/eval/arena/arena/run.sh +11 -0
- package/eval/arena/arena/schedule.py +79 -0
- package/eval/arena/arena/tournament.py +183 -0
- package/eval/arena/bomberman/bots/README.md +16 -0
- package/eval/arena/bomberman/bots/deepseek-v4.1-flash.cpp +447 -0
- package/eval/arena/bomberman/bots/doubao-seed-2.1.cpp +542 -0
- package/eval/arena/bomberman/bots/glm-5.3.cpp +406 -0
- package/eval/arena/bomberman/bots/gpt-6-astra.cpp +768 -0
- package/eval/arena/bomberman/bots/gpt-6-sol.cpp +444 -0
- package/eval/arena/bomberman/bots/kimi-k3.cpp +432 -0
- package/eval/arena/bomberman/bots/mimo-v2.6-pro.cpp +529 -0
- package/eval/arena/bomberman/bots/opus-5.5.cpp +548 -0
- package/eval/arena/bomberman/bots/qwen-3.8-max.cpp +836 -0
- package/eval/arena/bomberman/tournament/README.md +11 -0
- package/eval/arena/bomberman/tournament/bt.py +56 -0
- package/eval/arena/bomberman/tournament/evaluate.py +199 -0
- package/eval/arena/bomberman/tournament/run.py +79 -0
- package/eval/arena/bomberman/tournament/schedule-2to10.json.gz +0 -0
- package/eval/arena/bomberman/workspace/MANIFEST.json +13 -0
- package/eval/arena/bomberman/workspace/README.md +29 -0
- package/eval/arena/bomberman/workspace/RULES.md +100 -0
- package/eval/arena/bomberman/workspace/baseline.cpp +240 -0
- package/eval/arena/bomberman/workspace/clock.py +25 -0
- package/eval/arena/bomberman/workspace/engine/arena.py +198 -0
- package/eval/arena/bomberman/workspace/engine/build.py +35 -0
- package/eval/arena/bomberman/workspace/engine/engine.py +256 -0
- package/eval/arena/bomberman/workspace/engine/verify_replay.py +24 -0
- package/eval/arena/bomberman/workspace/run.cmd +28 -0
- package/eval/arena/bomberman/workspace/run.sh +16 -0
- package/eval/arena/bomberman/workspace/viewer/index.html +42 -0
- package/eval/arena/ctf/bots/README.md +18 -0
- package/eval/arena/ctf/bots/deepseek-v4.1-flash.cpp +601 -0
- package/eval/arena/ctf/bots/doubao-seed-2.1-turbo.cpp +320 -0
- package/eval/arena/ctf/bots/fable-5.1.cpp +634 -0
- package/eval/arena/ctf/bots/glm-5.3.cpp +689 -0
- package/eval/arena/ctf/bots/gpt-6-astra.cpp +657 -0
- package/eval/arena/ctf/bots/gpt-6.1-sol.cpp +725 -0
- package/eval/arena/ctf/bots/kimi-k3.cpp +667 -0
- package/eval/arena/ctf/bots/mimo-v2.6-pro.cpp +533 -0
- package/eval/arena/ctf/bots/opus-5.5.cpp +645 -0
- package/eval/arena/ctf/bots/qwen-3.8-max.cpp +726 -0
- package/eval/arena/ctf/bots/sonnet-5.5.cpp +569 -0
- package/eval/arena/ctf/tournament/README.md +15 -0
- package/eval/arena/ctf/tournament/formal-2to8.json +31 -0
- package/eval/arena/ctf/tournament/formal-9to15.json +31 -0
- package/eval/arena/ctf/workspace/README.md +41 -0
- package/eval/arena/ctf/workspace/RULES.md +119 -0
- package/eval/arena/ctf/workspace/arena.py +186 -0
- package/eval/arena/ctf/workspace/baseline.cpp +257 -0
- package/eval/arena/ctf/workspace/clock.py +15 -0
- package/eval/arena/ctf/workspace/core.py +265 -0
- package/eval/arena/ctf/workspace/engine.py +389 -0
- package/eval/arena/ctf/workspace/run.cmd +24 -0
- package/eval/arena/ctf/workspace/run.sh +11 -0
- package/eval/arena/ctf/workspace/viewer.html +71 -0
- package/eval/arena/land/bots/README.md +17 -0
- package/eval/arena/land/bots/deepseek-4.1f@workbuddy#1/bot.cpp +608 -0
- package/eval/arena/land/bots/glm-5.3@workbuddy#1/bot.cpp +521 -0
- package/eval/arena/land/bots/gpt-6-astra@codex#1/bot.cpp +373 -0
- package/eval/arena/land/bots/gpt-6-sol@codex#1/bot.cpp +341 -0
- package/eval/arena/land/bots/kimi-k3@workbuddy#1/bot.cpp +492 -0
- package/eval/arena/land/bots/mimo-2.6-pro@zcode#1/bot.cpp +652 -0
- package/eval/arena/land/bots/mystery-model@zcode#1/bot.cpp +466 -0
- package/eval/arena/land/bots/opus-5.5@claude-code#1/bot.cpp +630 -0
- package/eval/arena/land/bots/sonnet-5@claude-code#1/bot.cpp +619 -0
- package/eval/arena/land/tournament/make_replay_page.py +17 -0
- package/eval/arena/land/tournament/rate.py +290 -0
- package/eval/arena/land/tournament/round_robin.py +172 -0
- package/eval/arena/land/tournament/sparring.cpp +356 -0
- package/eval/arena/land/workspace/RULES.md +114 -0
- package/eval/arena/land/workspace/TASK.md +46 -0
- package/eval/arena/land/workspace/anchors/.build/greedy_f2e6d2dea5ca +0 -0
- package/eval/arena/land/workspace/anchors/greedy.cpp +194 -0
- package/eval/arena/land/workspace/anchors/random.cpp +80 -0
- package/eval/arena/land/workspace/clock.py +64 -0
- package/eval/arena/land/workspace/engine/build.py +33 -0
- package/eval/arena/land/workspace/engine/game.py +273 -0
- package/eval/arena/land/workspace/engine/referee.py +308 -0
- package/eval/arena/land/workspace/starter/.build/bot_be32b45b4699 +0 -0
- package/eval/arena/land/workspace/starter/bot.cpp +82 -0
- package/eval/arena/land/workspace/viewer/viewer.html +210 -0
- package/eval/arena/tank/bots/README.md +24 -0
- package/eval/arena/tank/bots/half1/deepseek-v4.1-flash.cpp +367 -0
- package/eval/arena/tank/bots/half1/doubao-seed-evolving.cpp +402 -0
- package/eval/arena/tank/bots/half1/gemini-4-argon.cpp +1132 -0
- package/eval/arena/tank/bots/half1/glm-5.3.cpp +551 -0
- package/eval/arena/tank/bots/half1/gpt-6-astra.cpp +233 -0
- package/eval/arena/tank/bots/half1/gpt-6.1-sol-ultrafast.cpp +350 -0
- package/eval/arena/tank/bots/half1/gpt-6.1-sol.cpp +78 -0
- package/eval/arena/tank/bots/half1/grok-4.7.cpp +1099 -0
- package/eval/arena/tank/bots/half1/haiku-5.5.cpp +210 -0
- package/eval/arena/tank/bots/half1/kimi-k3.cpp +353 -0
- package/eval/arena/tank/bots/half1/mimo-v2.6-pro.cpp +336 -0
- package/eval/arena/tank/bots/half1/muse-wonder-full.cpp +560 -0
- package/eval/arena/tank/bots/half1/opus-5.5.cpp +435 -0
- package/eval/arena/tank/bots/half1/qwen-3.8-max.cpp +537 -0
- package/eval/arena/tank/bots/half2/deepseek-v4.1-flash.cpp +425 -0
- package/eval/arena/tank/bots/half2/doubao-seed-evolving.cpp +308 -0
- package/eval/arena/tank/bots/half2/gemini-4-argon.cpp +1390 -0
- package/eval/arena/tank/bots/half2/glm-5.3.cpp +651 -0
- package/eval/arena/tank/bots/half2/gpt-6-astra.cpp +241 -0
- package/eval/arena/tank/bots/half2/gpt-6.1-sol-ultrafast.cpp +356 -0
- package/eval/arena/tank/bots/half2/gpt-6.1-sol.cpp +87 -0
- package/eval/arena/tank/bots/half2/grok-4.7.cpp +1205 -0
- package/eval/arena/tank/bots/half2/haiku-5.5.cpp +210 -0
- package/eval/arena/tank/bots/half2/kimi-k3.cpp +487 -0
- package/eval/arena/tank/bots/half2/mimo-v2.6-pro.cpp +336 -0
- package/eval/arena/tank/bots/half2/muse-wonder-full.cpp +560 -0
- package/eval/arena/tank/bots/half2/opus-5.5.cpp +476 -0
- package/eval/arena/tank/bots/half2/qwen-3.8-max.cpp +392 -0
- package/eval/arena/tank/tournament/README.md +24 -0
- package/eval/arena/tank/tournament/bt.py +218 -0
- package/eval/arena/tank/tournament/final.json +202 -0
- package/eval/arena/tank/tournament/halftime.json +146 -0
- package/eval/arena/tank/tournament/progress.json +911 -0
- package/eval/arena/tank/tournament/schedule.py +100 -0
- package/eval/arena/tank/tournament/sparring.cpp +928 -0
- package/eval/arena/tank/tournament/tournament.py +1426 -0
- package/eval/arena/tank/workspace/README.md +169 -0
- package/eval/arena/tank/workspace/RULES.md +443 -0
- package/eval/arena/tank/workspace/arena.py +862 -0
- package/eval/arena/tank/workspace/baseline.cpp +259 -0
- package/eval/arena/tank/workspace/clock.py +103 -0
- package/eval/arena/tank/workspace/core.py +712 -0
- package/eval/arena/tank/workspace/engine.py +573 -0
- package/eval/arena/tank/workspace/history/.lock +0 -0
- package/eval/arena/tank/workspace/history/20261017-193610-08715287f91f-bot.cpp +109 -0
- package/eval/arena/tank/workspace/history/20261017-193619-f00b2d5635fa-gpt-6-astra.cpp +241 -0
- package/eval/arena/tank/workspace/history/20261017-193643-63319e0a2b1a-gpt-6_1-sol-ultrafast.cpp +356 -0
- package/eval/arena/tank/workspace/history/20261017-193647-41c1341d0a8a-gemini-4-argon.cpp +1390 -0
- package/eval/arena/tank/workspace/history/20261017-193652-e34c0f45539a-gpt-6_1-sol.cpp +87 -0
- package/eval/arena/tank/workspace/history/20261017-193654-c026348ce96e-opus-5_5.cpp +476 -0
- package/eval/arena/tank/workspace/history/20261017-193655-4a21366e94d1-muse-wonder-full.cpp +560 -0
- package/eval/arena/tank/workspace/history/20261017-193657-dbc4e9969c52-mimo-v2_6-pro.cpp +336 -0
- package/eval/arena/tank/workspace/history/20261017-193703-723cd036eb31-grok-4_7.cpp +1205 -0
- package/eval/arena/tank/workspace/history/20261017-193707-1ebbb227be68-deepseek-v4_1-flash.cpp +425 -0
- package/eval/arena/tank/workspace/history/20261017-193709-aeb0e7202ad5-doubao-seed-evolving.cpp +308 -0
- package/eval/arena/tank/workspace/history/20261017-193710-b21ebeb9bc76-kimi-k3.cpp +487 -0
- package/eval/arena/tank/workspace/history/20261017-193712-54cd86531905-haiku-5_5.cpp +210 -0
- package/eval/arena/tank/workspace/history/20261017-193714-efdb8e35c0bf-qwen-3_8-max.cpp +392 -0
- package/eval/arena/tank/workspace/history/20261017-193715-681423c23fdb-glm-5_3.cpp +651 -0
- package/eval/arena/tank/workspace/history/20261017-193717-1a8b3c75181d-sparring.cpp +928 -0
- package/eval/arena/tank/workspace/history/20261017-193720-1e0803a541e8-baseline.cpp +259 -0
- package/eval/arena/tank/workspace/history/log.jsonl +103 -0
- package/eval/arena/tank/workspace/judge/cycles.hpp +53 -0
- package/eval/arena/tank/workspace/judge/fast.cpp +337 -0
- package/eval/arena/tank/workspace/judge/referee.cpp +125 -0
- package/eval/arena/tank/workspace/judge/speed.cpp +99 -0
- package/eval/arena/tank/workspace/run.cmd +31 -0
- package/eval/arena/tank/workspace/run.sh +23 -0
- package/eval/arena/tank/workspace/tank.hpp +1762 -0
- package/eval/arena/tank/workspace/tank_main.cpp +215 -0
- package/eval/arena/tank/workspace/viewer.html +1150 -0
- package/eval/arena/warehouse/workspace/SPEC.md +130 -0
- package/eval/arena/warehouse/workspace/TASK.md +71 -0
- package/eval/arena/warehouse/workspace/baseline/common.h +88 -0
- package/eval/arena/warehouse/workspace/baseline/main.cpp +27 -0
- package/eval/arena/warehouse/workspace/baseline/old_stuff.cpp +87 -0
- package/eval/arena/warehouse/workspace/baseline/sim.cpp +427 -0
- package/eval/arena/warehouse/workspace/baseline/util.cpp +220 -0
- package/eval/arena/warehouse/workspace/clock.py +42 -0
- package/eval/arena/warehouse/workspace/src/common.h +88 -0
- package/eval/arena/warehouse/workspace/src/main.cpp +27 -0
- package/eval/arena/warehouse/workspace/src/old_stuff.cpp +87 -0
- package/eval/arena/warehouse/workspace/src/sim.cpp +427 -0
- package/eval/arena/warehouse/workspace/src/util.cpp +220 -0
- package/eval/arena/warehouse/workspace/tools/bench.py +119 -0
- package/eval/arena/warehouse/workspace/tools/check_input.py +161 -0
- package/eval/arena/warehouse/workspace/tools/gen.py +132 -0
- package/eval/arena/warehouse/workspace/tools/measure.py +107 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.in +1420 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.out +9162 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.in +1465 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.out +3439 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_flood.in +2757 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_flood.out +3126 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_small.in +731 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_small.out +2578 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_wide.in +528 -0
- package/eval/arena/warehouse/workspace/workloads/public/pub_wide.out +2026 -0
- package/eval/bomberman.py +20 -0
- package/eval/commit0.py +21 -0
- package/eval/common.py +61 -0
- package/eval/ctf.py +20 -0
- package/eval/featurebench.py +25 -0
- package/eval/land.py +20 -0
- package/eval/legacy/bomberman-prices.json +4 -0
- package/eval/legacy/bomberman.py +161 -0
- package/eval/legacy/commit0.py +91 -0
- package/eval/legacy/ctf-prices.json +4 -0
- package/eval/legacy/ctf.py +178 -0
- package/eval/legacy/featurebench.py +103 -0
- package/eval/legacy/land-prices.json +4 -0
- package/eval/legacy/land.py +177 -0
- package/eval/legacy/swe_bench_pro.py +116 -0
- package/eval/legacy/tank-prices.json +3 -0
- package/eval/legacy/tank.py +408 -0
- package/eval/legacy/warehouse-prices.json +4 -0
- package/eval/legacy/warehouse.py +365 -0
- package/eval/swe-bench-pro.py +25 -0
- package/eval/tank.py +20 -0
- package/eval/warehouse.py +20 -0
- package/lib/client/index.js +376 -63
- package/lib/client/index.js.map +1 -1
- package/lib/index.js +684 -27
- package/package.json +2 -1
|
@@ -0,0 +1,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%
|