refactor(arch): redesign the code structure

This commit is contained in:
Horldsence
2026-09-10 22:38:27 +08:00
parent 21e8a8bf52
commit 83d2e24448
42 changed files with 2514 additions and 251 deletions
+8
View File
@@ -0,0 +1,8 @@
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
from .playback import PlaybackTransport
from .simulator import LocalSimulator, Source
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "Source"]
+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"))
+231
View File
@@ -0,0 +1,231 @@
"""自建本地模拟器:按附件1/2语义复现官方模拟器的可观测行为。
用途(P1 验收"自建回放测试"):不依赖官方模拟器即可测试客户端、
故障恢复与后续策略。真值 Source 只对测试端开放,策略端只能拿到响应。
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from math import atan2, degrees, hypot, isfinite
from random import Random
from time import time
from ..config import ProblemConfig
from ..protocol.rules import (ACTION_FIELDS, CLEAR_NO_TARGET, CLEAR_RESULTS,
CLEAR_SUCCESS, COMMON_FIELDS, DIRECTION,
EXIT_USER, MEASURE_RESULTS, NEAR, NO_SIGNAL,
POSITION_FIELDS)
from ..protocol.timing import clear_cost, measure_cost
@dataclass(frozen=True)
class Source:
"""一局真值。每个频道最多一个源 [题面];构造时强制检查。"""
channel: int
x: float
y: float
r_eff_m: float # 有效接收半径,题目范围 1000..1500
directional: bool = False
heading_deg: float = 0.0 # 定向源朝向;全向源忽略
class LocalSimulator:
"""handle(path, payload) -> (http_status, body_dict)。"""
def __init__(self, sources: list[Source], *, robot_id: str = "2026001",
config: ProblemConfig = ProblemConfig(),
error_half_deg: float | None = None, error_seed: int = 20260910,
rtt_ms: float = 40.0):
channels = [s.channel for s in sources]
if len(channels) != len(set(channels)):
raise ValueError("每个频道最多对应一个干扰源")
for s in sources:
if not (config.field.channel_min <= s.channel <= config.field.channel_max):
raise ValueError("频道超出 1..20")
if not s.directional and s.heading_deg != 0.0:
raise ValueError("全向源不应有朝向")
self.sources = list(sources)
self.robot_id = robot_id
self.config = config
self.field = config.field
self.cost = config.cost
self.error_half_deg = (self.field.bearing_error_half_deg
if error_half_deg is None else error_half_deg)
self._rng = Random(error_seed)
self._rtt_ms = rtt_ms
self.entered = False
self.exited = False
self.position = (0.0, 0.0)
self.channel = self.field.channel_min
self.virtual_time_s = 0.0
self.cleared: set[int] = set()
self._idempotency: dict[str, tuple[str, int, dict]] = {} # id -> (指纹, 状态码, 响应)
# ------------------------------------------------------------------ 主入口
def handle(self, path: str, payload) -> tuple[int, dict]:
if path not in ACTION_FIELDS:
return 404, self._reject(path)
if not isinstance(payload, dict):
return 400, self._reject(path)
# 1) 公共字段存在性与类型(缺失/类型错 -> 400 [题面])
for name, spec in COMMON_FIELDS.items():
value = payload.get(name)
if not isinstance(value, str) or (spec.max_bytes
and len(value.encode("utf-8")) > spec.max_bytes):
return 400, self._reject(path)
if not value:
return 400, self._reject(path)
# 2) 动作字段存在性 -> 400
spec = ACTION_FIELDS[path]
for name in spec:
if name not in payload:
return 400, self._reject(path)
# 3) 未知字段 -> 200 accepted=false(不占用 request_id [题面])
known = set(COMMON_FIELDS) | set(spec)
if set(payload) != known:
return 200, self._not_accepted()
# 4) arena / robot 不匹配 -> 200 accepted=false(不占用 request_id [题面])
if payload["arena_id"] != "default" or payload["robot_id"] != self.robot_id:
return 200, self._not_accepted()
# 4.5) position 下的未知字段同样 -> 200 accepted=false [题面 7.4]
if path in ("/measure", "/clear"):
position = payload.get("position")
if isinstance(position, dict) and set(position) - set(POSITION_FIELDS):
return 200, self._not_accepted()
# 5) 幂等:同一 ID 同一内容回放首次响应;不同内容 409 [题面]
request_id = payload["request_id"]
fingerprint = self._fingerprint(path, payload)
if request_id in self._idempotency:
seen_fp, seen_status, seen_body = self._idempotency[request_id]
if seen_fp == fingerprint:
return seen_status, dict(seen_body)
return 409, self._reject(path)
# 6) 业务状态机
status, body = self._execute(path, payload)
if status == 200 and body.get("accepted") is True:
self._idempotency[request_id] = (fingerprint, status, dict(body))
return status, body
# ------------------------------------------------------------------ 动作
def _execute(self, path: str, payload: dict) -> tuple[int, dict]:
if path == "/enter":
if self.entered or self.exited:
return 200, self._not_accepted()
self.entered = True
return 200, self._ok({
"max_virtual_duration_s": self.field.max_virtual_duration_s,
"max_real_duration_s": self.field.max_real_duration_s,
"remaining_real_duration_s": self.field.max_real_duration_s,
})
if not self.entered or self.exited:
return 200, self._not_accepted()
if path == "/exit":
self.exited = True
return 200, self._ok({"exit_reason": EXIT_USER})
# /measure 与 /clear 共同的字段校验 -> 400 [题面]
position = payload.get("position")
if not isinstance(position, dict) or not set(POSITION_FIELDS) <= set(position):
return 400, self._reject(path)
for key in ("x", "y"):
value = position[key]
if isinstance(value, bool) or not isinstance(value, (int, float)) \
or not isfinite(value) or abs(value) > self.field.coord_abs_limit_m:
return 400, self._reject(path)
channel = payload.get("channel")
if isinstance(channel, bool) or not isinstance(channel, (int, float)) \
or not float(channel).is_integer() \
or not self.field.channel_min <= channel <= self.field.channel_max:
return 400, self._reject(path)
channel = int(channel)
pos = (float(position["x"]), float(position["y"]))
if path == "/measure":
return self._measure(pos, channel)
return self._clear(pos, channel)
def _measure(self, pos, channel: int) -> tuple[int, dict]:
breakdown = measure_cost(self.position, pos, self.channel, channel, self.cost)
result, svd = self._observe(pos, channel)
self.position = pos
self.channel = channel # 只有合法 /measure 更新频道 [题面]
self._advance(breakdown.total_s)
body = {"measure_result": result}
if result == DIRECTION:
body["svd_deg"] = svd
return 200, self._ok(body)
def _clear(self, pos, channel: int) -> tuple[int, dict]:
source = self._source(channel)
success = (source is not None and source.channel not in self.cleared
and hypot(pos[0] - source.x, pos[1] - source.y)
<= self.field.clear_radius_m)
breakdown = clear_cost(self.position, pos, success, self.cost)
self.position = pos # /clear 不改变频道 [题面]
if success:
self.cleared.add(channel)
self._advance(breakdown.total_s)
result = CLEAR_SUCCESS if success else CLEAR_NO_TARGET
return 200, self._ok({"clear_result": result})
# ------------------------------------------------------------------ 观测
def _observe(self, pos, channel: int):
source = self._source(channel)
if source is None or channel in self.cleared:
return NO_SIGNAL, None
distance = hypot(pos[0] - source.x, pos[1] - source.y)
if distance > source.r_eff_m:
return NO_SIGNAL, None
if source.directional:
bearing_to_point = degrees(atan2(pos[1] - source.y, pos[0] - source.x))
if self._angular_gap(bearing_to_point, source.heading_deg) \
> self.field.directional_half_angle_deg:
return NO_SIGNAL, None
if distance <= self.field.near_radius_m:
return NEAR, None
true_bearing = degrees(atan2(source.y - pos[1], source.x - pos[0]))
error = self._rng.uniform(-self.error_half_deg, self.error_half_deg)
svd = round(true_bearing + error, self.field.bearing_decimals) % 360.0
return DIRECTION, svd
def _source(self, channel: int):
for source in self.sources:
if source.channel == channel:
return source
return None
@staticmethod
def _angular_gap(a: float, b: float) -> float:
return abs((a - b + 180.0) % 360.0 - 180.0)
# ------------------------------------------------------------------ 计时与响应
def _advance(self, seconds: float) -> None:
self.virtual_time_s += seconds
def _ok(self, extra: dict) -> dict:
self._rtt_ms += 40.0
body = {"accepted": True,
"real_timestamp_ms": 1_760_000_000_000.0 + self._rtt_ms,
"virtual_time_s": self.virtual_time_s, **extra}
return body
def _not_accepted(self) -> dict:
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
"virtual_time_s": 0} # accepted=false 时恒为 0 [题面]
def _reject(self, path: str) -> dict:
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
"virtual_time_s": 0, "detail": f"{path} 请求被拒绝"}
@staticmethod
def _fingerprint(path: str, payload) -> str:
canonical = json.dumps({"path": path, "payload": payload},
sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+53
View File
@@ -0,0 +1,53 @@
"""测试侧传输:把 LocalSimulator 挂到 Transport 接口 + 故障注入脚本。
故障脚本用于 P1 验收"失败重试不污染状态":
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
- ``None`` 正常放行。
脚本耗尽后一律放行。
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from ..protocol.transport import Transport, TransportError, TransportResponse
from .simulator import LocalSimulator
class LocalSimTransport(Transport):
"""内存回环:不经过网络栈的本地模拟器传输。"""
def __init__(self, simulator: LocalSimulator):
self.simulator = simulator
self.received: list[tuple[str, dict]] = []
def post(self, url, body, headers, timeout):
path = urlsplit(url).path
payload = json.loads(body.decode("utf-8"))
self.received.append((path, payload))
status, response = self.simulator.handle(path, payload)
return TransportResponse(status, json.dumps(response, ensure_ascii=False).encode("utf-8"))
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)