This commit is contained in:
Horldsence
2026-09-11 11:36:08 +08:00
parent 2d2a9a4e6a
commit a18a139a1d
93 changed files with 9245 additions and 3806 deletions
+18
View File
@@ -0,0 +1,18 @@
"""测试侧脚手架:把独立评测机(referee 包)接入本队传输与测试。
分层边界(与官方模拟器同构):
- 评测机本体在独立包 ``referee``(零 mcm26b 依赖),运行时代码绝不导入它;
- 本包只在测试/自检入口把 referee 的接口边界适配成 :class:`Transport`,
并提供故障注入与官方演练日志回放。
策略端只见 HTTP 响应;真值 Source 只存在于 referee 与测试端。
"""
from .playback import PlaybackTransport
from .scenes import mixed_scene, random_omni_scene, referee_spec
from .simulator import LocalSimulator
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "mixed_scene", "random_omni_scene",
"referee_spec"]
+38
View File
@@ -0,0 +1,38 @@
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
用于 P1 验收"本地状态机与官方响应一致":官方演练记录
(output/q1/practice/*_actions.json)逐条喂给客户端,客户端重建的
状态必须与当时服务器返回的虚拟时刻逐步吻合。
按录制顺序匹配(客户端的 request_id 生成规则与采集器不同,
故不参与匹配;故障重试场景请用 ScriptedFaultTransport)。
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from ..protocol.transport import Transport, TransportResponse
class PlaybackTransport(Transport):
"""events: 演练日志 [{path, request, response, ...}, ...]。"""
def __init__(self, events: list[dict]):
self.events = list(events)
self.index = 0
self.served: list[dict] = []
def post(self, url, body, headers, timeout):
if self.index >= len(self.events):
raise AssertionError("回放日志已耗尽仍有请求")
event = self.events[self.index]
path = urlsplit(url).path
if path != event["path"]:
raise AssertionError(
f"回放顺序不一致: 客户端请求 {path}, 录制预期 {event['path']}")
self.index += 1
self.served.append(event)
return TransportResponse(
200, json.dumps(event["response"], ensure_ascii=False).encode("utf-8"))
+55
View File
@@ -0,0 +1,55 @@
"""自检场景生成(测试端专用):委托 referee.scenes,映射本队配置。
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内
均匀分布、频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
均匀分布是测试假设,题面并未规定官方场景的概率分布。
"""
from __future__ import annotations
from referee import scenes as _referee_scenes
from ..config import ProblemConfig
def referee_spec(config: ProblemConfig = ProblemConfig(), *,
max_real_duration_s: float | None = None):
"""把本队 ProblemConfig 的题面常量映射为 referee 的 Spec。
max_real_duration_s 仅用于 --self-test 的实机预算覆盖(默认 1800s);
正式对接以服务器 /enter 返回为准,评测机默认保持题面 1200s。
"""
from referee import CostSpec, FieldSpec, Spec
field, cost = config.field, config.cost
return Spec(
field=FieldSpec(
omega_radius_m=field.omega_radius_m,
channel_min=field.channel_min, channel_max=field.channel_max,
source_count_min=field.source_count_min,
source_count_max=field.source_count_max,
r_eff_min_m=field.r_eff_min_m, r_eff_max_m=field.r_eff_max_m,
near_radius_m=field.near_radius_m, clear_radius_m=field.clear_radius_m,
bearing_error_half_deg=field.bearing_error_half_deg,
bearing_decimals=field.bearing_decimals,
coord_abs_limit_m=field.coord_abs_limit_m,
directional_half_angle_deg=field.directional_half_angle_deg,
max_virtual_duration_s=field.max_virtual_duration_s,
max_real_duration_s=(field.max_real_duration_s if max_real_duration_s is None
else max_real_duration_s),
test_window_s=field.test_window_s),
cost=CostSpec(speed_mps=cost.speed_mps, measure_s=cost.measure_s,
switch_s=cost.switch_s, clear_success_s=cost.clear_success_s,
clear_fail_s=cost.clear_fail_s))
def random_omni_scene(seed: int = 20260910, count: int | None = None,
config: ProblemConfig = ProblemConfig()):
"""全向源场景 [题面 问题3];签名沿用重构前 mcm26b.sim.scenes。"""
return _referee_scenes.random_omni_scene(seed, count, referee_spec(config))
def mixed_scene(seed: int = 20260910, count: int | None = None,
directional: int = 3, config: ProblemConfig = ProblemConfig()):
"""问题4混合场景:count 个源中 directional 个为定向源。"""
return _referee_scenes.mixed_scene(seed, count, directional, referee_spec(config))
+21
View File
@@ -0,0 +1,21 @@
"""兼容构造入口:本队 ProblemConfig -> referee.LocalSimulator。
评测机实现在独立包 referee(config= 换成了 spec=);本子类只做配置映射,
让既有测试与脚本继续以 ``config=ProblemConfig`` 构造,映射规则见
:func:`harness.scenes.referee_spec`。真值类型 Source 亦来自 referee。
"""
from __future__ import annotations
from referee import LocalSimulator as _RefereeLocalSimulator
from ..config import ProblemConfig
from .scenes import referee_spec
class LocalSimulator(_RefereeLocalSimulator):
def __init__(self, sources, *, robot_id: str = "2026001",
config: ProblemConfig = ProblemConfig(), **kwargs):
super().__init__(sources, robot_id=robot_id,
spec=referee_spec(config), **kwargs)
+61
View File
@@ -0,0 +1,61 @@
"""传输适配:评测机接口边界 <-> 本队 Transport 接口 + 故障注入脚本。
LocalSimTransport 把 referee 包的 handle_raw(与 HTTP 服务器共用的同一
边界实现)包成内存回环传输,不经过网络栈;故障注入语义与既有测试一致:
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
- ``None`` 正常放行。
脚本耗尽后一律放行。
"""
from __future__ import annotations
from urllib.parse import urlsplit
from referee.interface import handle_raw
from ..protocol.transport import Transport, TransportError, TransportResponse
class LocalSimTransport(Transport):
"""内存回环:把独立评测机接到 Transport 接口(与真实 HTTP 同一边界)。"""
def __init__(self, simulator):
# simulator: referee.LocalSimulator(类型注解走字符串,避免导入开销)
self.simulator = simulator
self.received: list[tuple[str, dict]] = []
def post(self, url, body, headers, timeout):
return self.request("POST", url, body, headers, timeout)
def request(self, method, url, body, headers, timeout=None):
"""验证原始请求边界;内存回环不模拟 TCP、UI 或连接限流。"""
target = urlsplit(url)
raw_target = target.path + (f"?{target.query}" if target.query else "")
result = handle_raw(self.simulator, method, raw_target,
list(headers.items()), bytes(body),
on_request=lambda path, payload, _res:
self.received.append((path, payload)))
return TransportResponse(result.status, result.body)
class ScriptedFaultTransport(Transport):
"""按脚本注入故障后转发给内层传输。"""
def __init__(self, inner: Transport, script: list):
self.inner = inner
self.script = list(script)
self.sent_requests: list[bytes] = []
def post(self, url, body, headers, timeout):
self.sent_requests.append(body)
fault = self.script.pop(0) if self.script else None
if fault == "drop_before":
raise TransportError("injected: connection refused before send")
if fault == "timeout_after":
self.inner.post(url, body, headers, timeout) # 服务器已受理
raise TransportError("injected: read timeout after server applied")
if isinstance(fault, tuple) and fault and fault[0] == "status":
return TransportResponse(int(fault[1]), b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}')
return self.inner.post(url, body, headers, timeout)