refactor(arch): redesign the code structure
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
|
||||
|
||||
from .playback import PlaybackTransport
|
||||
from .simulator import LocalSimulator, Source
|
||||
from .transport import LocalSimTransport, ScriptedFaultTransport
|
||||
|
||||
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
|
||||
"ScriptedFaultTransport", "Source"]
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user