python-library-ff14-the-hunt 0.0.8__tar.gz → 0.0.10__tar.gz

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 (71) hide show
  1. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +4 -0
  2. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/PKG-INFO +1 -1
  3. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/__init__.py +10 -4
  4. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/client.py +78 -10
  5. python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/common/http_request.py +3 -0
  6. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/common/urlopen_retry.py +40 -1
  7. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/ff14_the_hunt.py +29 -2
  8. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/cn.py +5 -4
  9. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/en.py +5 -4
  10. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/models.py +16 -8
  11. python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/poll/spawn_dedup.py +41 -0
  12. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/region_fetch.py +25 -1
  13. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/pyproject.toml +1 -1
  14. python_library_ff14_the_hunt-0.0.10/tests/test_bear_tracker_client.py +46 -0
  15. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_ff14_the_hunt_service.py +16 -0
  16. python_library_ff14_the_hunt-0.0.10/tests/test_spawn_dedup.py +112 -0
  17. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_urlopen_retry.py +40 -1
  18. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tools/build_locale_data.py +8 -7
  19. python_library_ff14_the_hunt-0.0.8/ff14_the_hunt/poll/spawn_dedup.py +0 -29
  20. python_library_ff14_the_hunt-0.0.8/tests/test_spawn_dedup.py +0 -55
  21. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
  22. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
  23. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/.gitignore +0 -0
  24. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/README.md +0 -0
  25. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example/__main__.py +0 -0
  26. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example/spawn_map_io.py +0 -0
  27. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/example.bat +0 -0
  28. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
  29. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
  30. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
  31. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/resources.py +0 -0
  32. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
  33. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/spawn_window.py +0 -0
  34. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
  35. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/common/__init__.py +0 -0
  36. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/__init__.py +0 -0
  37. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/__init__.py +0 -0
  38. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
  39. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
  40. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/detect.py +0 -0
  41. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/display.py +0 -0
  42. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/names.py +0 -0
  43. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
  44. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/locale/tag.py +0 -0
  45. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/__init__.py +0 -0
  46. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/loop.py +0 -0
  47. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/monitor_mode.py +0 -0
  48. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/scheduler.py +0 -0
  49. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/sleep_plan.py +0 -0
  50. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/sleep_settings.py +0 -0
  51. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/poll/window_remaining.py +0 -0
  52. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/__init__.py +0 -0
  53. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/attach.py +0 -0
  54. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
  55. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/layout.py +0 -0
  56. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
  57. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/points.py +0 -0
  58. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/ff14_the_hunt/spawn_map/region_image.py +0 -0
  59. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/test.bat +0 -0
  60. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/__init__.py +0 -0
  61. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_fate_timer.py +0 -0
  62. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_locale_cn.py +0 -0
  63. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_monitor_mode.py +0 -0
  64. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_scheduler.py +0 -0
  65. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_poll_sleep.py +0 -0
  66. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_recent_spawn_detection.py +0 -0
  67. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_map_data.py +0 -0
  68. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_map_points.py +0 -0
  69. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_spawn_window.py +0 -0
  70. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/tests/test_untimed_marks_filter.py +0 -0
  71. {python_library_ff14_the_hunt-0.0.8 → python_library_ff14_the_hunt-0.0.10}/update.bat +0 -0
@@ -7,6 +7,10 @@ description: ff14_the_hunt 包:要求与决议;最新在上。
7
7
 
8
8
  (规则见 `~/.cursor/skills/agent-project-changelog/SKILL.md`。)
9
9
 
10
+ ## 2026-07-19
11
+
12
+ - **Decision**: polling reports the first observed trigger-window `OPEN` event once per hunt/world/death cycle. `HuntMarkRecord.newly_opened` distinguishes the open-window event, and `newly_spawned` remains the compatibility signal for report-worthy messages so existing ff14hub callbacks that read `newly_spawned_marks` do not miss the 100% open-window transition.
13
+
10
14
  ## 2026-06-10
11
15
 
12
16
  - **决议**:自动轮询:爬取、回调或循环内任意异常均记录后等待 60 秒继续,仅 ``KeyboardInterrupt`` / ``SystemExit`` 可终止服务。发布 ``0.0.6``。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-ff14-the-hunt
3
- Version: 0.0.8
3
+ Version: 0.0.10
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: pydantic>=2.0
6
6
  Provides-Extra: dev
@@ -1,5 +1,9 @@
1
- from ff14_the_hunt.ff14_the_hunt import FF14TheHunt
2
- from ff14_the_hunt.bear_tracker.timer_theme import BEAR_TRACKER_BAR_HEX, bar_hex
1
+ from ff14_the_hunt.ff14_the_hunt import FF14TheHunt
2
+ from ff14_the_hunt.bear_tracker.client import (
3
+ BearTrackerBlockedError,
4
+ BearTrackerRequestError,
5
+ )
6
+ from ff14_the_hunt.bear_tracker.timer_theme import BEAR_TRACKER_BAR_HEX, bar_hex
3
7
  from ff14_the_hunt.models import (
4
8
  HuntCrawlPacket,
5
9
  HuntMarkRecord,
@@ -32,8 +36,10 @@ from ff14_the_hunt.poll import (
32
36
  )
33
37
 
34
38
  __all__ = [
35
- "BEAR_TRACKER_BAR_HEX",
36
- "FF14TheHunt",
39
+ "BEAR_TRACKER_BAR_HEX",
40
+ "BearTrackerBlockedError",
41
+ "BearTrackerRequestError",
42
+ "FF14TheHunt",
37
43
  "HuntDisplayLocale",
38
44
  "HuntCrawlPacket",
39
45
  "HuntMarkRecord",
@@ -1,13 +1,39 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import threading
5
+ import time
4
6
  import urllib.error
5
7
  import urllib.request
6
8
  from typing import Any
7
9
 
8
- from ff14_the_hunt.common.urlopen_retry import urlopen_read
10
+ from ff14_the_hunt.common.urlopen_retry import (
11
+ retry_after_seconds_from_headers,
12
+ urlopen_read,
13
+ )
14
+ from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
9
15
 
10
16
  DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
17
+ _BLOCKED_HTTP_CODES = frozenset({403, 429})
18
+
19
+
20
+ class BearTrackerRequestError(RuntimeError):
21
+ """Bear Tracker API request failed after HTTP retry handling."""
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ *,
27
+ status_code: int | None = None,
28
+ retry_after_seconds: float | None = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+ self.retry_after_seconds = retry_after_seconds
33
+
34
+
35
+ class BearTrackerBlockedError(BearTrackerRequestError):
36
+ """Bear Tracker returned a block or rate-limit status."""
11
37
 
12
38
 
13
39
  class BearTrackerClient:
@@ -18,9 +44,15 @@ class BearTrackerClient:
18
44
  *,
19
45
  base_url: str = DEFAULT_BASE_URL,
20
46
  timeout_seconds: float = 120.0,
47
+ min_request_interval_seconds: float = 1.0,
48
+ user_agent: str = DEFAULT_USER_AGENT,
21
49
  ) -> None:
22
50
  self._base_url = base_url.rstrip("/")
23
51
  self._timeout_seconds = timeout_seconds
52
+ self._min_request_interval_seconds = min_request_interval_seconds
53
+ self._user_agent = user_agent
54
+ self._last_request_at = 0.0
55
+ self._request_lock = threading.Lock()
24
56
 
25
57
  @property
26
58
  def base_url(self) -> str:
@@ -30,6 +62,14 @@ class BearTrackerClient:
30
62
  def timeout_seconds(self) -> float:
31
63
  return self._timeout_seconds
32
64
 
65
+ @property
66
+ def min_request_interval_seconds(self) -> float:
67
+ return self._min_request_interval_seconds
68
+
69
+ @property
70
+ def user_agent(self) -> str:
71
+ return self._user_agent
72
+
33
73
  def sync_session(self) -> dict[str, Any]:
34
74
  """拉取会话与 ``resources``(含 DatabaseHunt、SpawnPoint、DataCenters)。"""
35
75
  return self._post("/syncSession", {})
@@ -89,23 +129,51 @@ class BearTrackerClient:
89
129
  request = urllib.request.Request(
90
130
  url,
91
131
  data=encoded,
92
- headers={
93
- "User-Agent": "python-library-ff14-the-hunt",
94
- "Content-Type": "application/json",
95
- "Accept": "application/json",
96
- "Origin": "https://tracker.beartoolkit.com",
97
- "Referer": "https://tracker.beartoolkit.com/timer",
98
- },
132
+ headers=self._headers(),
99
133
  method="POST",
100
134
  )
101
135
  try:
136
+ self._pace_request()
102
137
  raw = urlopen_read(request, timeout=self._timeout_seconds)
103
138
  except urllib.error.HTTPError as exc:
104
139
  detail = exc.read().decode("utf-8", errors="replace")
105
- raise RuntimeError(
106
- f"Bear Tracker API {path} failed: HTTP {exc.code}: {detail}"
140
+ retry_after = retry_after_seconds_from_headers(exc.headers)
141
+ message = f"Bear Tracker API {path} failed: HTTP {exc.code}: {detail}"
142
+ error_type = (
143
+ BearTrackerBlockedError
144
+ if exc.code in _BLOCKED_HTTP_CODES
145
+ else BearTrackerRequestError
146
+ )
147
+ raise error_type(
148
+ message,
149
+ status_code=exc.code,
150
+ retry_after_seconds=retry_after,
107
151
  ) from exc
152
+ except urllib.error.URLError as exc:
153
+ raise BearTrackerRequestError(f"Bear Tracker API {path} failed: {exc}") from exc
108
154
  parsed = json.loads(raw.decode("utf-8"))
109
155
  if not isinstance(parsed, dict):
110
156
  raise RuntimeError(f"unexpected response type from {path}")
111
157
  return parsed
158
+
159
+ def _headers(self) -> dict[str, str]:
160
+ return {
161
+ "User-Agent": self._user_agent,
162
+ "Content-Type": "application/json",
163
+ "Accept": "application/json, text/plain, */*",
164
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
165
+ "Origin": "https://tracker.beartoolkit.com",
166
+ "Referer": "https://tracker.beartoolkit.com/timer",
167
+ }
168
+
169
+ def _pace_request(self) -> None:
170
+ if self._min_request_interval_seconds <= 0:
171
+ return
172
+ with self._request_lock:
173
+ now = time.monotonic()
174
+ wait_seconds = self._min_request_interval_seconds - (
175
+ now - self._last_request_at
176
+ )
177
+ if wait_seconds > 0:
178
+ time.sleep(wait_seconds)
179
+ self._last_request_at = time.monotonic()
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_USER_AGENT = "ff14-the-hunt/0.0.9"
@@ -4,11 +4,36 @@ import ssl
4
4
  import time
5
5
  import urllib.error
6
6
  import urllib.request
7
+ from collections.abc import Callable
8
+ from email.utils import parsedate_to_datetime
9
+ from random import uniform
7
10
 
8
11
  _RETRYABLE_HTTP_CODES = frozenset({408, 429, 500, 502, 503, 504})
9
12
  _DEFAULT_MAX_ATTEMPTS = 4
10
13
  _DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0
11
14
  _DEFAULT_MAX_BACKOFF_SECONDS = 8.0
15
+ _DEFAULT_MAX_RETRY_AFTER_SECONDS = 300.0
16
+ _DEFAULT_JITTER_RATIO = 0.15
17
+
18
+
19
+ def retry_after_seconds_from_headers(headers: object) -> float | None:
20
+ if headers is None or not hasattr(headers, "get"):
21
+ return None
22
+ raw = headers.get("Retry-After") # type: ignore[attr-defined]
23
+ if raw is None:
24
+ return None
25
+ text = str(raw).strip()
26
+ if not text:
27
+ return None
28
+ try:
29
+ seconds = float(text)
30
+ except ValueError:
31
+ try:
32
+ retry_at = parsedate_to_datetime(text)
33
+ except (TypeError, ValueError):
34
+ return None
35
+ seconds = retry_at.timestamp() - time.time()
36
+ return max(0.0, seconds)
12
37
 
13
38
 
14
39
  def is_retryable_urlopen_error(exc: BaseException) -> bool:
@@ -36,6 +61,9 @@ def urlopen_read(
36
61
  max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
37
62
  initial_backoff_seconds: float = _DEFAULT_INITIAL_BACKOFF_SECONDS,
38
63
  max_backoff_seconds: float = _DEFAULT_MAX_BACKOFF_SECONDS,
64
+ max_retry_after_seconds: float = _DEFAULT_MAX_RETRY_AFTER_SECONDS,
65
+ jitter_ratio: float = _DEFAULT_JITTER_RATIO,
66
+ sleep: Callable[[float], None] = time.sleep,
39
67
  ) -> bytes:
40
68
  """``urlopen`` 读响应体;瞬时网络或 TLS 失败时指数退避重试。
41
69
 
@@ -56,6 +84,10 @@ def urlopen_read(
56
84
  """
57
85
  if max_attempts < 1:
58
86
  raise ValueError("max_attempts must be >= 1")
87
+ if max_retry_after_seconds < 0:
88
+ raise ValueError("max_retry_after_seconds must be >= 0")
89
+ if jitter_ratio < 0:
90
+ raise ValueError("jitter_ratio must be >= 0")
59
91
 
60
92
  last_exc: BaseException | None = None
61
93
  backoff = initial_backoff_seconds
@@ -67,7 +99,14 @@ def urlopen_read(
67
99
  last_exc = exc
68
100
  if attempt + 1 >= max_attempts or not is_retryable_urlopen_error(exc):
69
101
  raise
70
- time.sleep(min(backoff, max_backoff_seconds))
102
+ wait_seconds = min(backoff, max_backoff_seconds)
103
+ if isinstance(exc, urllib.error.HTTPError):
104
+ retry_after = retry_after_seconds_from_headers(exc.headers)
105
+ if retry_after is not None:
106
+ wait_seconds = min(retry_after, max_retry_after_seconds)
107
+ if jitter_ratio > 0 and wait_seconds > 0:
108
+ wait_seconds *= uniform(1.0 - jitter_ratio, 1.0 + jitter_ratio)
109
+ sleep(wait_seconds)
71
110
  backoff = min(backoff * 2.0, max_backoff_seconds)
72
111
 
73
112
  if last_exc is not None:
@@ -5,9 +5,13 @@ import threading
5
5
  import time
6
6
  from collections.abc import Callable
7
7
 
8
- from ff14_the_hunt.bear_tracker.client import BearTrackerClient
8
+ from ff14_the_hunt.bear_tracker.client import (
9
+ BearTrackerBlockedError,
10
+ BearTrackerClient,
11
+ )
9
12
  from ff14_the_hunt.bear_tracker.enrich import build_hunt_record, mark_has_display_timer
10
13
  from ff14_the_hunt.bear_tracker.resources import BearResources
14
+ from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
11
15
  from ff14_the_hunt.locale.cn import normalize_patch_codes
12
16
  from ff14_the_hunt.models import (
13
17
  HuntCrawlPacket,
@@ -24,6 +28,7 @@ HuntCrawlCallback = Callable[[HuntCrawlPacket], None]
24
28
 
25
29
  _log = logging.getLogger(__name__)
26
30
  _FETCH_FAILURE_RETRY_SECONDS = 60.0
31
+ _BLOCKED_RETRY_SECONDS = 900.0
27
32
 
28
33
 
29
34
  class FF14TheHunt:
@@ -40,6 +45,8 @@ class FF14TheHunt:
40
45
  regions: list[str] | None = None,
41
46
  base_url: str | None = None,
42
47
  timeout_seconds: float = 120.0,
48
+ min_request_interval_seconds: float = 1.0,
49
+ user_agent: str = DEFAULT_USER_AGENT,
43
50
  active_poll_interval_seconds: float = 600.0,
44
51
  recent_poll_interval_seconds: float = 300.0,
45
52
  fallback_poll_interval_seconds: float = 1800.0,
@@ -71,7 +78,11 @@ class FF14TheHunt:
71
78
  include_spawn_maps: 为刚刷新记录拉取站点区域原图 PNG base64。
72
79
  include_untimed_marks: 是否保留无计时的占位行(SS 级噬灵王、维护占位等)。
73
80
  """
74
- kwargs: dict[str, float | str] = {"timeout_seconds": timeout_seconds}
81
+ kwargs: dict[str, float | str] = {
82
+ "timeout_seconds": timeout_seconds,
83
+ "min_request_interval_seconds": min_request_interval_seconds,
84
+ "user_agent": user_agent,
85
+ }
75
86
  if base_url is not None:
76
87
  kwargs["base_url"] = base_url
77
88
  self._client = BearTrackerClient(**kwargs)
@@ -291,6 +302,8 @@ class FF14TheHunt:
291
302
  self._map_fetcher = RegionMapFetcher(
292
303
  site_root=site_root_from_api_base(self._client.base_url),
293
304
  timeout_seconds=self._client.timeout_seconds,
305
+ min_request_interval_seconds=self._client.min_request_interval_seconds,
306
+ user_agent=self._client.user_agent,
294
307
  )
295
308
  return self._map_fetcher
296
309
 
@@ -340,6 +353,20 @@ class FF14TheHunt:
340
353
  marks = self._scheduler.fetch()
341
354
  except (KeyboardInterrupt, SystemExit):
342
355
  raise
356
+ except BearTrackerBlockedError as exc:
357
+ retry_seconds = (
358
+ exc.retry_after_seconds
359
+ if exc.retry_after_seconds is not None
360
+ else _BLOCKED_RETRY_SECONDS
361
+ )
362
+ retry_seconds = max(retry_seconds, _FETCH_FAILURE_RETRY_SECONDS)
363
+ _log.warning(
364
+ "Bear Tracker refused crawl; retry in %s seconds: %s",
365
+ retry_seconds,
366
+ exc,
367
+ )
368
+ wait_or_stop(stop_event, retry_seconds)
369
+ return
343
370
  except Exception as exc:
344
371
  _log.warning("狩猎爬取失败,%s 秒后重试:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
345
372
  wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
@@ -121,10 +121,11 @@ def mark_to_display_dict(
121
121
  "上次死亡时间": mark.last_death_time,
122
122
  "上次标记时间": mark.last_mark_time,
123
123
  "失踪计数": mark.missing_counter,
124
- "维护计时": mark.is_maintenance,
125
- "刚刷新": mark.recently_spawned,
126
- "新检出": mark.newly_spawned,
127
- }
124
+ "维护计时": mark.is_maintenance,
125
+ "刚刷新": mark.recently_spawned,
126
+ "刚开窗": mark.newly_opened,
127
+ "新检出": mark.newly_spawned,
128
+ }
128
129
  if mark.fate_last_seen is not None:
129
130
  item["FATE最近发现"] = mark.fate_last_seen
130
131
  if mark.fate_last_death is not None:
@@ -108,10 +108,11 @@ def mark_to_display_dict(
108
108
  "last_death_time": mark.last_death_time,
109
109
  "last_mark_time": mark.last_mark_time,
110
110
  "missing_counter": mark.missing_counter,
111
- "is_maintenance": mark.is_maintenance,
112
- "recently_spawned": mark.recently_spawned,
113
- "newly_spawned": mark.newly_spawned,
114
- }
111
+ "is_maintenance": mark.is_maintenance,
112
+ "recently_spawned": mark.recently_spawned,
113
+ "newly_opened": mark.newly_opened,
114
+ "newly_spawned": mark.newly_spawned,
115
+ }
115
116
  if mark.fate_last_seen is not None:
116
117
  item["fate_last_seen"] = mark.fate_last_seen
117
118
  if mark.fate_last_death is not None:
@@ -137,11 +137,15 @@ class HuntMarkRecord(BaseModel):
137
137
  default=None,
138
138
  description="仅刚刷新且启用区域原图时填充",
139
139
  )
140
- recently_spawned: bool = False
141
- newly_spawned: bool = Field(
142
- default=False,
143
- description="本轮会话内首次检出的刚刷新;同一死亡周期不重复为 True",
144
- )
140
+ recently_spawned: bool = False
141
+ newly_opened: bool = Field(
142
+ default=False,
143
+ description="First reported trigger-window open event in this polling session.",
144
+ )
145
+ newly_spawned: bool = Field(
146
+ default=False,
147
+ description="First report-worthy event in this polling session.",
148
+ )
145
149
  raw_timer: dict[str, Any] = Field(default_factory=dict)
146
150
 
147
151
 
@@ -193,6 +197,10 @@ class HuntCrawlPacket(BaseModel):
193
197
  marks: list[HuntMarkRecord] = Field(default_factory=list, description="解析后的狩猎记录")
194
198
  query: HuntQueryFilter = Field(description="本次爬取使用的筛选条件")
195
199
 
196
- @property
197
- def newly_spawned_marks(self) -> list[HuntMarkRecord]:
198
- return [mark for mark in self.marks if mark.newly_spawned]
200
+ @property
201
+ def newly_spawned_marks(self) -> list[HuntMarkRecord]:
202
+ return [mark for mark in self.marks if mark.newly_spawned]
203
+
204
+ @property
205
+ def newly_opened_marks(self) -> list[HuntMarkRecord]:
206
+ return [mark for mark in self.marks if mark.newly_opened]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from ff14_the_hunt.models import HuntMarkRecord, SpawnWindowPhase
4
+
5
+ SpawnCycleKey = tuple[str, str, float | None]
6
+
7
+
8
+ def spawn_cycle_key(mark: HuntMarkRecord) -> SpawnCycleKey:
9
+ """Return the per-hunt death cycle used for one-shot reports."""
10
+ return (mark.hunt_key, mark.world_name, mark.last_death_time)
11
+
12
+
13
+ class SpawnReportTracker:
14
+ """Track one-shot report events across polling rounds."""
15
+
16
+ def __init__(self) -> None:
17
+ self._reported_spawn_cycles: set[SpawnCycleKey] = set()
18
+ self._reported_open_cycles: set[SpawnCycleKey] = set()
19
+
20
+ def apply(self, marks: list[HuntMarkRecord]) -> None:
21
+ for mark in marks:
22
+ cycle = spawn_cycle_key(mark)
23
+ mark.newly_opened = self._should_report_open_window(mark, cycle)
24
+ mark.newly_spawned = self._should_report_spawn(mark, cycle) or mark.newly_opened
25
+
26
+ def _should_report_spawn(self, mark: HuntMarkRecord, cycle: SpawnCycleKey) -> bool:
27
+ if not mark.recently_spawned:
28
+ return False
29
+ if cycle in self._reported_spawn_cycles:
30
+ return False
31
+ self._reported_spawn_cycles.add(cycle)
32
+ return True
33
+
34
+ def _should_report_open_window(self, mark: HuntMarkRecord, cycle: SpawnCycleKey) -> bool:
35
+ trigger = mark.trigger_timer
36
+ if trigger is None or trigger.phase != SpawnWindowPhase.OPEN:
37
+ return False
38
+ if cycle in self._reported_open_cycles:
39
+ return False
40
+ self._reported_open_cycles.add(cycle)
41
+ return True
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import threading
4
+ import time
3
5
  import urllib.error
4
6
  import urllib.parse
5
7
  import urllib.request
6
8
 
9
+ from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
7
10
  from ff14_the_hunt.common.urlopen_retry import urlopen_read
8
11
 
9
12
 
@@ -30,9 +33,15 @@ class RegionMapFetcher:
30
33
  *,
31
34
  site_root: str,
32
35
  timeout_seconds: float = 120.0,
36
+ min_request_interval_seconds: float = 1.0,
37
+ user_agent: str = DEFAULT_USER_AGENT,
33
38
  ) -> None:
34
39
  self._site_root = site_root.rstrip("/")
35
40
  self._timeout_seconds = timeout_seconds
41
+ self._min_request_interval_seconds = min_request_interval_seconds
42
+ self._user_agent = user_agent
43
+ self._last_request_at = 0.0
44
+ self._request_lock = threading.Lock()
36
45
  self._cache: dict[str, bytes] = {}
37
46
 
38
47
  @property
@@ -58,12 +67,15 @@ class RegionMapFetcher:
58
67
  request = urllib.request.Request(
59
68
  url,
60
69
  headers={
61
- "User-Agent": "python-library-ff14-the-hunt",
70
+ "User-Agent": self._user_agent,
71
+ "Accept": "image/avif,image/webp,image/apng,image/png,image/*,*/*;q=0.8",
72
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
62
73
  "Referer": f"{self._site_root}/timer",
63
74
  },
64
75
  method="GET",
65
76
  )
66
77
  try:
78
+ self._pace_request()
67
79
  data = urlopen_read(request, timeout=self._timeout_seconds)
68
80
  except urllib.error.HTTPError as exc:
69
81
  detail = exc.read().decode("utf-8", errors="replace")
@@ -74,3 +86,15 @@ class RegionMapFetcher:
74
86
  raise RuntimeError(f"region map {region} failed: {exc}") from exc
75
87
  self._cache[region] = data
76
88
  return data
89
+
90
+ def _pace_request(self) -> None:
91
+ if self._min_request_interval_seconds <= 0:
92
+ return
93
+ with self._request_lock:
94
+ now = time.monotonic()
95
+ wait_seconds = self._min_request_interval_seconds - (
96
+ now - self._last_request_at
97
+ )
98
+ if wait_seconds > 0:
99
+ time.sleep(wait_seconds)
100
+ self._last_request_at = time.monotonic()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-library-ff14-the-hunt"
7
- version = "0.0.8"
7
+ version = "0.0.10"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
10
  "pydantic>=2.0",
@@ -0,0 +1,46 @@
1
+ import urllib.error
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+
6
+ from ff14_the_hunt.bear_tracker.client import (
7
+ BearTrackerBlockedError,
8
+ BearTrackerClient,
9
+ )
10
+
11
+
12
+ def test_post_raises_blocked_error_for_forbidden() -> None:
13
+ client = BearTrackerClient(min_request_interval_seconds=0.0)
14
+ http_error = urllib.error.HTTPError(
15
+ url="https://tracker.beartoolkit.com/api/syncSession",
16
+ code=403,
17
+ msg="Forbidden",
18
+ hdrs={"Retry-After": "60"},
19
+ fp=None,
20
+ )
21
+
22
+ with patch(
23
+ "ff14_the_hunt.bear_tracker.client.urlopen_read",
24
+ side_effect=http_error,
25
+ ):
26
+ with pytest.raises(BearTrackerBlockedError) as raised:
27
+ client.sync_session()
28
+
29
+ assert raised.value.status_code == 403
30
+ assert raised.value.retry_after_seconds == 60.0
31
+
32
+
33
+ def test_client_paces_consecutive_requests() -> None:
34
+ client = BearTrackerClient(min_request_interval_seconds=1.0)
35
+ sleep = MagicMock()
36
+
37
+ with patch("ff14_the_hunt.bear_tracker.client.time.sleep", sleep):
38
+ with patch(
39
+ "ff14_the_hunt.bear_tracker.client.time.monotonic",
40
+ side_effect=[10.0, 10.0, 10.2, 11.0],
41
+ ):
42
+ client._pace_request() # type: ignore[attr-defined]
43
+ client._pace_request() # type: ignore[attr-defined]
44
+
45
+ sleep.assert_called_once()
46
+ assert sleep.call_args.args[0] == pytest.approx(0.8)
@@ -4,6 +4,7 @@ import urllib.error
4
4
  from unittest.mock import MagicMock, patch
5
5
 
6
6
  from ff14_the_hunt import FF14TheHunt, HuntCrawlPacket, HuntQueryFilter, HuntRankKind
7
+ from ff14_the_hunt.bear_tracker.client import BearTrackerBlockedError
7
8
  from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
8
9
  from ff14_the_hunt.models import (
9
10
  HuntMarkRecord,
@@ -136,6 +137,21 @@ def test_poll_loop_survives_fetch_failure() -> None:
136
137
  assert len(seen) == 1
137
138
 
138
139
 
140
+ def test_poll_loop_uses_blocked_retry_after() -> None:
141
+ hunt, mock_query = _hunt_with_mock_query()
142
+ mock_query.side_effect = BearTrackerBlockedError(
143
+ "blocked",
144
+ status_code=429,
145
+ retry_after_seconds=180.0,
146
+ )
147
+ stop_event = threading.Event()
148
+
149
+ with patch("ff14_the_hunt.ff14_the_hunt.wait_or_stop", return_value=False) as wait:
150
+ hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
151
+
152
+ wait.assert_called_once_with(stop_event, 180.0)
153
+
154
+
139
155
  def test_wait_or_stop_wakes_early_on_stop() -> None:
140
156
  stop_event = threading.Event()
141
157
 
@@ -0,0 +1,112 @@
1
+ from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
2
+ from ff14_the_hunt.models import (
3
+ HuntCrawlPacket,
4
+ HuntMarkRecord,
5
+ HuntQueryFilter,
6
+ SpawnWindowPhase,
7
+ TimerBarColor,
8
+ TimerKind,
9
+ )
10
+ from ff14_the_hunt.poll.spawn_dedup import SpawnReportTracker, spawn_cycle_key
11
+
12
+
13
+ def _mark(
14
+ *,
15
+ hunt_key: str = "Foo",
16
+ world: str = "Bar",
17
+ last_death: float = 1000.0,
18
+ recently: bool = True,
19
+ phase: SpawnWindowPhase | None = None,
20
+ ) -> HuntMarkRecord:
21
+ trigger_timer = None
22
+ if phase is not None:
23
+ trigger_timer = build_timer_display(
24
+ kind=TimerKind.TRIGGER,
25
+ phase=phase,
26
+ bar_color=TimerBarColor.SUCCESS
27
+ if phase == SpawnWindowPhase.OPEN
28
+ else TimerBarColor.ERROR,
29
+ counts_up=phase == SpawnWindowPhase.OPEN,
30
+ summary="test",
31
+ )
32
+ return HuntMarkRecord(
33
+ hunt_key=hunt_key,
34
+ hunt_name=hunt_key,
35
+ world_name=world,
36
+ last_death_time=last_death,
37
+ recently_spawned=recently,
38
+ trigger_timer=trigger_timer,
39
+ )
40
+
41
+
42
+ def test_spawn_cycle_key_uses_last_death() -> None:
43
+ mark = _mark(last_death=42.0)
44
+ assert spawn_cycle_key(mark) == ("Foo", "Bar", 42.0)
45
+
46
+
47
+ def test_tracker_marks_first_recent_as_new() -> None:
48
+ tracker = SpawnReportTracker()
49
+ marks = [_mark()]
50
+ tracker.apply(marks)
51
+ assert marks[0].newly_spawned is True
52
+
53
+
54
+ def test_tracker_skips_repeat_within_same_cycle() -> None:
55
+ tracker = SpawnReportTracker()
56
+ first = [_mark()]
57
+ tracker.apply(first)
58
+ second = [_mark()]
59
+ tracker.apply(second)
60
+ assert second[0].newly_spawned is False
61
+ assert second[0].recently_spawned is True
62
+
63
+
64
+ def test_tracker_reports_again_after_new_death_cycle() -> None:
65
+ tracker = SpawnReportTracker()
66
+ tracker.apply([_mark(last_death=10.0)])
67
+ later = [_mark(last_death=20.0)]
68
+ tracker.apply(later)
69
+ assert later[0].newly_spawned is True
70
+
71
+
72
+ def test_tracker_clears_newly_when_not_recent() -> None:
73
+ tracker = SpawnReportTracker()
74
+ marks = [_mark(recently=False)]
75
+ tracker.apply(marks)
76
+ assert marks[0].newly_spawned is False
77
+
78
+
79
+ def test_tracker_marks_first_open_window_as_new() -> None:
80
+ tracker = SpawnReportTracker()
81
+ marks = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
82
+ tracker.apply(marks)
83
+ assert marks[0].newly_opened is True
84
+ assert marks[0].newly_spawned is True
85
+
86
+ packet = HuntCrawlPacket(
87
+ crawled_at=1.0,
88
+ next_fetch_at=2.0,
89
+ marks=marks,
90
+ query=HuntQueryFilter(),
91
+ )
92
+ assert packet.newly_opened_marks == marks
93
+ assert packet.newly_spawned_marks == marks
94
+
95
+
96
+ def test_tracker_skips_repeat_open_window_cycle() -> None:
97
+ tracker = SpawnReportTracker()
98
+ first = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
99
+ tracker.apply(first)
100
+ second = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
101
+ tracker.apply(second)
102
+ assert second[0].newly_opened is False
103
+ assert second[0].newly_spawned is False
104
+
105
+
106
+ def test_tracker_reports_spawn_after_open_window_cycle() -> None:
107
+ tracker = SpawnReportTracker()
108
+ tracker.apply([_mark(recently=False, phase=SpawnWindowPhase.OPEN)])
109
+ later = [_mark(recently=True, phase=SpawnWindowPhase.OPEN)]
110
+ tracker.apply(later)
111
+ assert later[0].newly_opened is False
112
+ assert later[0].newly_spawned is True
@@ -4,7 +4,11 @@ from unittest.mock import MagicMock, patch
4
4
 
5
5
  import pytest
6
6
 
7
- from ff14_the_hunt.common.urlopen_retry import is_retryable_urlopen_error, urlopen_read
7
+ from ff14_the_hunt.common.urlopen_retry import (
8
+ is_retryable_urlopen_error,
9
+ retry_after_seconds_from_headers,
10
+ urlopen_read,
11
+ )
8
12
 
9
13
 
10
14
  def test_is_retryable_urlopen_error_for_ssl_eof() -> None:
@@ -45,6 +49,41 @@ def test_urlopen_read_retries_transient_failure() -> None:
45
49
  assert mock_urlopen.call_count == 2
46
50
 
47
51
 
52
+ def test_retry_after_seconds_from_headers_delta_seconds() -> None:
53
+ assert retry_after_seconds_from_headers({"Retry-After": "12"}) == 12.0
54
+
55
+
56
+ def test_urlopen_read_respects_retry_after_header() -> None:
57
+ request = MagicMock()
58
+ response = MagicMock()
59
+ response.read.return_value = b"ok"
60
+ response.__enter__.return_value = response
61
+ response.__exit__.return_value = False
62
+ http_error = urllib.error.HTTPError(
63
+ url="https://example.test",
64
+ code=429,
65
+ msg="Too Many Requests",
66
+ hdrs={"Retry-After": "7"},
67
+ fp=None,
68
+ )
69
+ sleep = MagicMock()
70
+
71
+ with patch(
72
+ "ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
73
+ side_effect=[http_error, response],
74
+ ):
75
+ raw = urlopen_read(
76
+ request,
77
+ timeout=5.0,
78
+ max_attempts=2,
79
+ jitter_ratio=0.0,
80
+ sleep=sleep,
81
+ )
82
+
83
+ assert raw == b"ok"
84
+ sleep.assert_called_once_with(7.0)
85
+
86
+
48
87
  def test_urlopen_read_raises_after_exhausted_retries() -> None:
49
88
  request = MagicMock()
50
89
  ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
@@ -9,9 +9,10 @@ import re
9
9
  import sys
10
10
  import urllib.error
11
11
  import urllib.request
12
- from pathlib import Path
13
-
14
- from ff14_the_hunt import FF14TheHunt
12
+ from pathlib import Path
13
+
14
+ from ff14_the_hunt import FF14TheHunt
15
+ from ff14_the_hunt.common.http_request import DEFAULT_USER_AGENT
15
16
 
16
17
  DATAMINING_BASE = (
17
18
  "https://cdn.jsdelivr.net/gh/InfSein/ffxiv-datamining-mixed@master"
@@ -26,10 +27,10 @@ HUNT_PREFIX_ZH: dict[str, str] = {
26
27
 
27
28
 
28
29
  def _download_text(url: str, *, timeout: float = 180.0) -> str:
29
- request = urllib.request.Request(
30
- url,
31
- headers={"User-Agent": "python-library-ff14-the-hunt"},
32
- )
30
+ request = urllib.request.Request(
31
+ url,
32
+ headers={"User-Agent": DEFAULT_USER_AGENT},
33
+ )
33
34
  with urllib.request.urlopen(request, timeout=timeout) as response:
34
35
  return response.read().decode("utf-8-sig")
35
36
 
@@ -1,29 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from ff14_the_hunt.models import HuntMarkRecord
4
-
5
- SpawnCycleKey = tuple[str, str, float | None]
6
-
7
-
8
- def spawn_cycle_key(mark: HuntMarkRecord) -> SpawnCycleKey:
9
- """同一死亡周期内只应上报一次「新检出」。"""
10
- return (mark.hunt_key, mark.world_name, mark.last_death_time)
11
-
12
-
13
- class SpawnReportTracker:
14
- """跨轮询记住已上报的刷新周期,避免宽限期内重复当作新刷新。"""
15
-
16
- def __init__(self) -> None:
17
- self._reported_cycles: set[SpawnCycleKey] = set()
18
-
19
- def apply(self, marks: list[HuntMarkRecord]) -> None:
20
- for mark in marks:
21
- if not mark.recently_spawned:
22
- mark.newly_spawned = False
23
- continue
24
- cycle = spawn_cycle_key(mark)
25
- if cycle in self._reported_cycles:
26
- mark.newly_spawned = False
27
- continue
28
- mark.newly_spawned = True
29
- self._reported_cycles.add(cycle)
@@ -1,55 +0,0 @@
1
- from ff14_the_hunt.models import HuntMarkRecord
2
- from ff14_the_hunt.poll.spawn_dedup import SpawnReportTracker, spawn_cycle_key
3
-
4
-
5
- def _mark(
6
- *,
7
- hunt_key: str = "Foo",
8
- world: str = "Bar",
9
- last_death: float = 1000.0,
10
- recently: bool = True,
11
- ) -> HuntMarkRecord:
12
- return HuntMarkRecord(
13
- hunt_key=hunt_key,
14
- hunt_name=hunt_key,
15
- world_name=world,
16
- last_death_time=last_death,
17
- recently_spawned=recently,
18
- )
19
-
20
-
21
- def test_spawn_cycle_key_uses_last_death() -> None:
22
- mark = _mark(last_death=42.0)
23
- assert spawn_cycle_key(mark) == ("Foo", "Bar", 42.0)
24
-
25
-
26
- def test_tracker_marks_first_recent_as_new() -> None:
27
- tracker = SpawnReportTracker()
28
- marks = [_mark()]
29
- tracker.apply(marks)
30
- assert marks[0].newly_spawned is True
31
-
32
-
33
- def test_tracker_skips_repeat_within_same_cycle() -> None:
34
- tracker = SpawnReportTracker()
35
- first = [_mark()]
36
- tracker.apply(first)
37
- second = [_mark()]
38
- tracker.apply(second)
39
- assert second[0].newly_spawned is False
40
- assert second[0].recently_spawned is True
41
-
42
-
43
- def test_tracker_reports_again_after_new_death_cycle() -> None:
44
- tracker = SpawnReportTracker()
45
- tracker.apply([_mark(last_death=10.0)])
46
- later = [_mark(last_death=20.0)]
47
- tracker.apply(later)
48
- assert later[0].newly_spawned is True
49
-
50
-
51
- def test_tracker_clears_newly_when_not_recent() -> None:
52
- tracker = SpawnReportTracker()
53
- marks = [_mark(recently=False)]
54
- tracker.apply(marks)
55
- assert marks[0].newly_spawned is False