python-library-ff14-the-hunt 0.0.10__tar.gz → 0.0.11__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.10 → python_library_ff14_the_hunt-0.0.11}/PKG-INFO +1 -1
  2. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/spawn_window.py +19 -7
  3. python_library_ff14_the_hunt-0.0.11/ff14_the_hunt/common/http_request.py +3 -0
  4. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/cn.py +6 -6
  5. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/en.py +5 -5
  6. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/models.py +15 -15
  7. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/spawn_dedup.py +34 -32
  8. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/attach.py +17 -12
  9. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/pyproject.toml +1 -1
  10. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_dedup.py +172 -73
  11. python_library_ff14_the_hunt-0.0.11/tests/test_spawn_map_data.py +194 -0
  12. python_library_ff14_the_hunt-0.0.10/ff14_the_hunt/common/http_request.py +0 -3
  13. python_library_ff14_the_hunt-0.0.10/tests/test_spawn_map_data.py +0 -74
  14. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +0 -0
  15. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
  16. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
  17. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/.gitignore +0 -0
  18. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/README.md +0 -0
  19. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/example/__main__.py +0 -0
  20. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/example/spawn_map_io.py +0 -0
  21. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/example.bat +0 -0
  22. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/__init__.py +0 -0
  23. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
  24. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/client.py +0 -0
  25. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
  26. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
  27. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/resources.py +0 -0
  28. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
  29. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
  30. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/common/__init__.py +0 -0
  31. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/common/urlopen_retry.py +0 -0
  32. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/ff14_the_hunt.py +0 -0
  33. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/__init__.py +0 -0
  34. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/data/__init__.py +0 -0
  35. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
  36. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
  37. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/detect.py +0 -0
  38. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/display.py +0 -0
  39. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/names.py +0 -0
  40. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
  41. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/locale/tag.py +0 -0
  42. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/__init__.py +0 -0
  43. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/loop.py +0 -0
  44. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/monitor_mode.py +0 -0
  45. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/scheduler.py +0 -0
  46. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/sleep_plan.py +0 -0
  47. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/sleep_settings.py +0 -0
  48. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/poll/window_remaining.py +0 -0
  49. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/__init__.py +0 -0
  50. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
  51. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/layout.py +0 -0
  52. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
  53. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/points.py +0 -0
  54. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/region_fetch.py +0 -0
  55. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/ff14_the_hunt/spawn_map/region_image.py +0 -0
  56. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/test.bat +0 -0
  57. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/__init__.py +0 -0
  58. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_bear_tracker_client.py +0 -0
  59. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_fate_timer.py +0 -0
  60. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_ff14_the_hunt_service.py +0 -0
  61. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_locale_cn.py +0 -0
  62. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_monitor_mode.py +0 -0
  63. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_poll_scheduler.py +0 -0
  64. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_poll_sleep.py +0 -0
  65. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_recent_spawn_detection.py +0 -0
  66. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_map_points.py +0 -0
  67. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_spawn_window.py +0 -0
  68. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_untimed_marks_filter.py +0 -0
  69. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tests/test_urlopen_retry.py +0 -0
  70. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/tools/build_locale_data.py +0 -0
  71. {python_library_ff14_the_hunt-0.0.10 → python_library_ff14_the_hunt-0.0.11}/update.bat +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-ff14-the-hunt
3
- Version: 0.0.10
3
+ Version: 0.0.11
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: pydantic>=2.0
6
6
  Provides-Extra: dev
@@ -85,13 +85,25 @@ def compute_trigger_timer(
85
85
  )
86
86
 
87
87
 
88
- def is_window_open(timer: TimerDisplay | None) -> bool:
89
- return timer is not None and timer.phase == SpawnWindowPhase.OPEN
90
-
91
-
92
- def is_recently_in_window(
93
- timer: TimerDisplay | None,
94
- *,
88
+ def is_window_open(timer: TimerDisplay | None) -> bool:
89
+ return timer is not None and timer.phase == SpawnWindowPhase.OPEN
90
+
91
+
92
+ def is_window_report_ready(timer: TimerDisplay | None) -> bool:
93
+ if timer is None:
94
+ return False
95
+ if timer.phase == SpawnWindowPhase.CAPPED:
96
+ return True
97
+ if timer.phase != SpawnWindowPhase.OPEN:
98
+ return False
99
+ if timer.progress_percent is None:
100
+ return False
101
+ return timer.progress_percent >= 100.0
102
+
103
+
104
+ def is_recently_in_window(
105
+ timer: TimerDisplay | None,
106
+ *,
95
107
  grace_seconds: float = 900.0,
96
108
  ) -> bool:
97
109
  if timer is None or timer.phase != SpawnWindowPhase.OPEN:
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_USER_AGENT = "ff14-the-hunt/0.0.8"
@@ -121,11 +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_opened,
127
- "新检出": mark.newly_spawned,
128
- }
124
+ "维护计时": mark.is_maintenance,
125
+ "刚刷新": mark.recently_spawned,
126
+ "开窗100%": mark.newly_opened,
127
+ "新检出": mark.newly_spawned,
128
+ }
129
129
  if mark.fate_last_seen is not None:
130
130
  item["FATE最近发现"] = mark.fate_last_seen
131
131
  if mark.fate_last_death is not None:
@@ -153,7 +153,7 @@ def mark_to_display_dict(
153
153
  "像素Y": point.pixel_y,
154
154
  "格点X": point.grid_x,
155
155
  "格点Y": point.grid_y,
156
- "可能触发": point.active,
156
+ "可能触发": point.active,
157
157
  }
158
158
  for point in mark.spawn_points
159
159
  ]
@@ -108,11 +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_opened": mark.newly_opened,
114
- "newly_spawned": mark.newly_spawned,
115
- }
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
+ }
116
116
  if mark.fate_last_seen is not None:
117
117
  item["fate_last_seen"] = mark.fate_last_seen
118
118
  if mark.fate_last_death is not None:
@@ -58,7 +58,7 @@ class MapCoordinate(BaseModel):
58
58
  default=None,
59
59
  description="区域原图上的 Y 像素;norm_y * 图高",
60
60
  )
61
- active: bool | None = Field(default=None, description="是否仍是可能触发点位")
61
+ active: bool | None = Field(default=None, description="是否仍是可能触发点位")
62
62
 
63
63
 
64
64
  class SpawnMapLayout(BaseModel):
@@ -127,7 +127,7 @@ class HuntMarkRecord(BaseModel):
127
127
  fate_timer: TimerDisplay | None = None
128
128
  spawn_points: list[MapCoordinate] = Field(
129
129
  default_factory=list,
130
- description="仅刚刷新记录填充;只保留可能触发点位",
130
+ description="仅刚刷新记录填充;只保留可能触发点位",
131
131
  )
132
132
  spawn_map_layout: SpawnMapLayout | None = Field(
133
133
  default=None,
@@ -137,15 +137,15 @@ class HuntMarkRecord(BaseModel):
137
137
  default=None,
138
138
  description="仅刚刷新且启用区域原图时填充",
139
139
  )
140
- recently_spawned: bool = False
140
+ recently_spawned: bool = False
141
141
  newly_opened: bool = Field(
142
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.",
143
+ description="First reported 100-percent trigger-window event in this polling session.",
148
144
  )
145
+ newly_spawned: bool = Field(
146
+ default=False,
147
+ description="First report-worthy event in this polling session.",
148
+ )
149
149
  raw_timer: dict[str, Any] = Field(default_factory=dict)
150
150
 
151
151
 
@@ -197,10 +197,10 @@ class HuntCrawlPacket(BaseModel):
197
197
  marks: list[HuntMarkRecord] = Field(default_factory=list, description="解析后的狩猎记录")
198
198
  query: HuntQueryFilter = Field(description="本次爬取使用的筛选条件")
199
199
 
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]
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]
@@ -1,41 +1,43 @@
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
-
1
+ from __future__ import annotations
2
+
3
+ from ff14_the_hunt.bear_tracker.spawn_window import is_window_report_ready
4
+ from ff14_the_hunt.models import HuntMarkRecord
5
+
6
+ SpawnCycleKey = tuple[str, str, float | None]
7
+
8
+
9
+ def spawn_cycle_key(mark: HuntMarkRecord) -> SpawnCycleKey:
10
+ """Return the per-hunt death cycle used for one-shot reports."""
11
+ return (mark.hunt_key, mark.world_name, mark.last_death_time)
12
+
13
+
14
+ class SpawnReportTracker:
15
+ """Track one-shot report events across polling rounds."""
16
+
17
+ def __init__(self) -> None:
18
+ self._reported_spawn_cycles: set[SpawnCycleKey] = set()
19
+ self._reported_open_cycles: set[SpawnCycleKey] = set()
20
+
21
+ def apply(self, marks: list[HuntMarkRecord]) -> None:
22
+ for mark in marks:
23
+ cycle = spawn_cycle_key(mark)
24
+ mark.newly_opened = self._should_report_open_window(mark, cycle)
25
+ mark.newly_spawned = self._should_report_spawn(mark, cycle) or mark.newly_opened
26
+
26
27
  def _should_report_spawn(self, mark: HuntMarkRecord, cycle: SpawnCycleKey) -> bool:
27
28
  if not mark.recently_spawned:
28
29
  return False
30
+ if not is_window_report_ready(mark.trigger_timer):
31
+ return False
29
32
  if cycle in self._reported_spawn_cycles:
30
33
  return False
31
- self._reported_spawn_cycles.add(cycle)
32
- return True
33
-
34
+ self._reported_spawn_cycles.add(cycle)
35
+ return True
36
+
34
37
  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:
38
+ if not is_window_report_ready(mark.trigger_timer):
37
39
  return False
38
40
  if cycle in self._reported_open_cycles:
39
41
  return False
40
- self._reported_open_cycles.add(cycle)
41
- return True
42
+ self._reported_open_cycles.add(cycle)
43
+ return True
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from ff14_the_hunt.bear_tracker.resources import BearResources
5
+ from ff14_the_hunt.bear_tracker.resources import BearResources
6
+ from ff14_the_hunt.bear_tracker.spawn_window import is_window_report_ready
6
7
  from ff14_the_hunt.bear_tracker.spawn_points import list_map_coordinates
7
8
  from ff14_the_hunt.models import HuntMarkRecord
8
9
 
@@ -13,25 +14,29 @@ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher
13
14
  from ff14_the_hunt.spawn_map.region_image import build_region_map_image
14
15
 
15
16
 
16
- def _spawn_entry_for_record(
17
- record: HuntMarkRecord,
18
- resources: BearResources,
19
- ) -> dict[str, Any] | None:
17
+ def _spawn_entry_for_record(
18
+ record: HuntMarkRecord,
19
+ resources: BearResources,
20
+ ) -> dict[str, Any] | None:
20
21
  meta = resources.hunt_meta(record.hunt_key)
21
22
  map_key = resources.spawn_map_key(record.hunt_key, meta)
22
23
  if not map_key:
23
24
  return None
24
- return resources.spawn_point.get(map_key)
25
-
26
-
27
- def populate_spawn_points(
25
+ return resources.spawn_point.get(map_key)
26
+
27
+
28
+ def should_attach_spawn_details(record: HuntMarkRecord) -> bool:
29
+ return is_window_report_ready(record.trigger_timer)
30
+
31
+
32
+ def populate_spawn_points(
28
33
  record: HuntMarkRecord,
29
34
  *,
30
35
  resources: BearResources,
31
36
  spawn_states: dict[str, Any] | None,
32
37
  ) -> None:
33
38
  """为刚刷新记录填充 ``spawn_points`` 与 ``spawn_map_layout``。"""
34
- if not record.recently_spawned:
39
+ if not should_attach_spawn_details(record):
35
40
  record.spawn_points = []
36
41
  record.spawn_map_layout = None
37
42
  record.region_map = None
@@ -49,7 +54,7 @@ def attach_region_map(
49
54
  ) -> None:
50
55
  """拉取站点区域原图,并补全刷点像素坐标。"""
51
56
  record.region_map = None
52
- if not record.recently_spawned or not record.spawn_points:
57
+ if not should_attach_spawn_details(record) or not record.spawn_points:
53
58
  return
54
59
  region = resolve_region_name(record.region)
55
60
  if not region:
@@ -80,7 +85,7 @@ def enrich_recent_spawn_details(
80
85
  for row in timer_rows
81
86
  }
82
87
  for record in records:
83
- if not record.recently_spawned:
88
+ if not should_attach_spawn_details(record):
84
89
  record.spawn_points = []
85
90
  record.spawn_map_layout = None
86
91
  record.region_map = None
@@ -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.10"
7
+ version = "0.0.11"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
10
  "pydantic>=2.0",
@@ -1,42 +1,44 @@
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",
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
17
  last_death: float = 1000.0,
18
18
  recently: bool = True,
19
19
  phase: SpawnWindowPhase | None = None,
20
+ progress_percent: float | None = None,
20
21
  ) -> 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,
22
+ trigger_timer = None
23
+ if phase is not None:
24
+ trigger_timer = build_timer_display(
25
+ kind=TimerKind.TRIGGER,
26
+ phase=phase,
27
+ bar_color=TimerBarColor.SUCCESS
28
+ if phase == SpawnWindowPhase.OPEN
29
+ else TimerBarColor.ERROR,
29
30
  counts_up=phase == SpawnWindowPhase.OPEN,
31
+ progress_percent=progress_percent,
30
32
  summary="test",
31
33
  )
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
- )
34
+ return HuntMarkRecord(
35
+ hunt_key=hunt_key,
36
+ hunt_name=hunt_key,
37
+ world_name=world,
38
+ last_death_time=last_death,
39
+ recently_spawned=recently,
40
+ trigger_timer=trigger_timer,
41
+ )
40
42
 
41
43
 
42
44
  def test_spawn_cycle_key_uses_last_death() -> None:
@@ -44,29 +46,58 @@ def test_spawn_cycle_key_uses_last_death() -> None:
44
46
  assert spawn_cycle_key(mark) == ("Foo", "Bar", 42.0)
45
47
 
46
48
 
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
49
+ def test_tracker_marks_first_recent_as_new() -> None:
50
+ tracker = SpawnReportTracker()
51
+ marks = [
52
+ _mark(
53
+ phase=SpawnWindowPhase.OPEN,
54
+ progress_percent=100.0,
55
+ )
56
+ ]
57
+ tracker.apply(marks)
58
+ assert marks[0].newly_spawned is True
52
59
 
53
60
 
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
61
+ def test_tracker_skips_repeat_within_same_cycle() -> None:
62
+ tracker = SpawnReportTracker()
63
+ first = [
64
+ _mark(
65
+ phase=SpawnWindowPhase.OPEN,
66
+ progress_percent=100.0,
67
+ )
68
+ ]
69
+ tracker.apply(first)
70
+ second = [
71
+ _mark(
72
+ phase=SpawnWindowPhase.OPEN,
73
+ progress_percent=100.0,
74
+ )
75
+ ]
76
+ tracker.apply(second)
77
+ assert second[0].newly_spawned is False
78
+ assert second[0].recently_spawned is True
62
79
 
63
80
 
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
81
+ def test_tracker_reports_again_after_new_death_cycle() -> None:
82
+ tracker = SpawnReportTracker()
83
+ tracker.apply(
84
+ [
85
+ _mark(
86
+ last_death=10.0,
87
+ phase=SpawnWindowPhase.OPEN,
88
+ progress_percent=100.0,
89
+ )
90
+ ]
91
+ )
92
+ later = [
93
+ _mark(
94
+ last_death=20.0,
95
+ phase=SpawnWindowPhase.OPEN,
96
+ progress_percent=100.0,
97
+ )
98
+ ]
99
+ tracker.apply(later)
100
+ assert later[0].newly_spawned is True
70
101
 
71
102
 
72
103
  def test_tracker_clears_newly_when_not_recent() -> None:
@@ -76,37 +107,105 @@ def test_tracker_clears_newly_when_not_recent() -> None:
76
107
  assert marks[0].newly_spawned is False
77
108
 
78
109
 
79
- def test_tracker_marks_first_open_window_as_new() -> None:
110
+ def test_tracker_does_not_report_recent_before_100_percent() -> None:
80
111
  tracker = SpawnReportTracker()
81
- marks = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
112
+ marks = [
113
+ _mark(
114
+ recently=True,
115
+ phase=SpawnWindowPhase.OPEN,
116
+ progress_percent=99.0,
117
+ )
118
+ ]
82
119
  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
120
+ assert marks[0].newly_opened is False
121
+ assert marks[0].newly_spawned is False
122
+
123
+
124
+ def test_tracker_does_not_report_open_window_before_100_percent() -> None:
125
+ tracker = SpawnReportTracker()
126
+ marks = [
127
+ _mark(
128
+ recently=False,
129
+ phase=SpawnWindowPhase.OPEN,
130
+ progress_percent=99.0,
131
+ )
132
+ ]
133
+ tracker.apply(marks)
134
+ assert marks[0].newly_opened is False
135
+ assert marks[0].newly_spawned is False
94
136
 
95
137
 
138
+ def test_tracker_marks_first_100_percent_open_window_as_new() -> None:
139
+ tracker = SpawnReportTracker()
140
+ marks = [
141
+ _mark(
142
+ recently=False,
143
+ phase=SpawnWindowPhase.OPEN,
144
+ progress_percent=100.0,
145
+ )
146
+ ]
147
+ tracker.apply(marks)
148
+ assert marks[0].newly_opened is True
149
+ assert marks[0].newly_spawned is True
150
+
151
+ packet = HuntCrawlPacket(
152
+ crawled_at=1.0,
153
+ next_fetch_at=2.0,
154
+ marks=marks,
155
+ query=HuntQueryFilter(),
156
+ )
157
+ assert packet.newly_opened_marks == marks
158
+ assert packet.newly_spawned_marks == marks
159
+
160
+
96
161
  def test_tracker_skips_repeat_open_window_cycle() -> None:
97
162
  tracker = SpawnReportTracker()
98
- first = [_mark(recently=False, phase=SpawnWindowPhase.OPEN)]
163
+ first = [
164
+ _mark(
165
+ recently=False,
166
+ phase=SpawnWindowPhase.OPEN,
167
+ progress_percent=100.0,
168
+ )
169
+ ]
99
170
  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
-
171
+ second = [
172
+ _mark(
173
+ recently=False,
174
+ phase=SpawnWindowPhase.OPEN,
175
+ progress_percent=100.0,
176
+ )
177
+ ]
178
+ tracker.apply(second)
179
+ assert second[0].newly_opened is False
180
+ assert second[0].newly_spawned is False
181
+
182
+
106
183
  def test_tracker_reports_spawn_after_open_window_cycle() -> None:
107
184
  tracker = SpawnReportTracker()
108
- tracker.apply([_mark(recently=False, phase=SpawnWindowPhase.OPEN)])
109
- later = [_mark(recently=True, phase=SpawnWindowPhase.OPEN)]
185
+ tracker.apply(
186
+ [
187
+ _mark(
188
+ recently=False,
189
+ phase=SpawnWindowPhase.OPEN,
190
+ progress_percent=100.0,
191
+ )
192
+ ]
193
+ )
194
+ later = [
195
+ _mark(
196
+ recently=True,
197
+ phase=SpawnWindowPhase.OPEN,
198
+ progress_percent=100.0,
199
+ )
200
+ ]
110
201
  tracker.apply(later)
111
202
  assert later[0].newly_opened is False
112
203
  assert later[0].newly_spawned is True
204
+
205
+
206
+ def test_tracker_marks_capped_window_as_new() -> None:
207
+ tracker = SpawnReportTracker()
208
+ marks = [_mark(recently=False, phase=SpawnWindowPhase.CAPPED)]
209
+ tracker.apply(marks)
210
+ assert marks[0].newly_opened is True
211
+ assert marks[0].newly_spawned is True
@@ -0,0 +1,194 @@
1
+ import base64
2
+ import struct
3
+ import zlib
4
+
5
+ from ff14_the_hunt.bear_tracker.resources import BearResources
6
+ from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
7
+ from ff14_the_hunt.models import (
8
+ HuntMarkRecord,
9
+ MapCoordinate,
10
+ SpawnWindowPhase,
11
+ TimerBarColor,
12
+ TimerKind,
13
+ )
14
+ from ff14_the_hunt.spawn_map.coordinates import with_pixel_coordinates
15
+ from ff14_the_hunt.spawn_map.attach import enrich_recent_spawn_details
16
+ from ff14_the_hunt.spawn_map.layout import layout_from_spawn_entry
17
+ from ff14_the_hunt.spawn_map.png_meta import read_png_dimensions
18
+
19
+
20
+ def _minimal_png(width: int, height: int) -> bytes:
21
+ signature = b"\x89PNG\r\n\x1a\n"
22
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
23
+ ihdr_chunk = (
24
+ b"IHDR"
25
+ + ihdr
26
+ + struct.pack(">I", zlib.crc32(b"IHDR" + ihdr) & 0xFFFFFFFF)
27
+ )
28
+ ihdr_len = struct.pack(">I", len(ihdr))
29
+ return signature + ihdr_len + ihdr_chunk
30
+
31
+
32
+ def test_read_png_dimensions() -> None:
33
+ data = _minimal_png(2048, 1024)
34
+ assert read_png_dimensions(data) == (2048, 1024)
35
+
36
+
37
+ def test_layout_from_spawn_entry() -> None:
38
+ layout = layout_from_spawn_entry(
39
+ {
40
+ "Dimensions": [41, 41],
41
+ "DisplayPoints": 8,
42
+ "Version": 21,
43
+ },
44
+ )
45
+ assert layout is not None
46
+ assert layout.grid_scale == 41.0
47
+ assert layout.display_points == 8
48
+ assert layout.version == 21
49
+
50
+
51
+ def test_with_pixel_coordinates() -> None:
52
+ points = [
53
+ MapCoordinate(point_key="SpawnPoint01", x=0.5, y=0.25),
54
+ ]
55
+ enriched = with_pixel_coordinates(points, image_width=2000, image_height=1000)
56
+ assert enriched[0].pixel_x == 1000.0
57
+ assert enriched[0].pixel_y == 250.0
58
+
59
+
60
+ def test_region_map_image_url_encodes_special_characters() -> None:
61
+ from ff14_the_hunt.spawn_map.region_fetch import region_map_image_url
62
+
63
+ url = region_map_image_url("https://tracker.beartoolkit.com", "Yak T'el")
64
+ assert (
65
+ url
66
+ == "https://tracker.beartoolkit.com/static/images/HuntRegions/Yak%20T%27el.png"
67
+ )
68
+ plain = region_map_image_url("https://tracker.beartoolkit.com", "Shaaloani")
69
+ assert plain == "https://tracker.beartoolkit.com/static/images/HuntRegions/Shaaloani.png"
70
+
71
+
72
+ def test_build_region_map_image_round_trip() -> None:
73
+ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher
74
+ from ff14_the_hunt.spawn_map.region_image import build_region_map_image
75
+
76
+ png = _minimal_png(100, 80)
77
+ fetcher = RegionMapFetcher(site_root="https://tracker.beartoolkit.com")
78
+ fetcher._cache["Shaaloani"] = png
79
+ image = build_region_map_image(region="Shaaloani", fetcher=fetcher)
80
+ assert image is not None
81
+ assert image.width == 100
82
+ assert image.height == 80
83
+ assert base64.b64decode(image.data_base64) == png
84
+
85
+
86
+ def test_open_window_before_100_percent_does_not_attach_spawn_details() -> None:
87
+ resources = _spawn_detail_resources()
88
+ mark = _open_window_mark(progress_percent=99.0)
89
+ enrich_recent_spawn_details(
90
+ [mark],
91
+ [_timer_row()],
92
+ resources=resources,
93
+ fetcher=None,
94
+ load_spawn_states=lambda _row, _resources: {},
95
+ include_region_map=False,
96
+ )
97
+
98
+ assert mark.spawn_points == []
99
+ assert mark.spawn_map_layout is None
100
+ assert mark.region_map is None
101
+
102
+
103
+ def test_recent_before_100_percent_does_not_attach_spawn_details() -> None:
104
+ resources = _spawn_detail_resources()
105
+ mark = _open_window_mark(progress_percent=99.0)
106
+ mark.recently_spawned = True
107
+ enrich_recent_spawn_details(
108
+ [mark],
109
+ [_timer_row()],
110
+ resources=resources,
111
+ fetcher=None,
112
+ load_spawn_states=lambda _row, _resources: {},
113
+ include_region_map=False,
114
+ )
115
+
116
+ assert mark.spawn_points == []
117
+ assert mark.spawn_map_layout is None
118
+ assert mark.region_map is None
119
+
120
+
121
+ def test_100_percent_open_window_attaches_spawn_details_and_region_map() -> None:
122
+ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher
123
+
124
+ resources = _spawn_detail_resources()
125
+ mark = _open_window_mark(progress_percent=100.0)
126
+ fetcher = RegionMapFetcher(site_root="https://tracker.beartoolkit.com")
127
+ fetcher._cache["Living Memory"] = _minimal_png(100, 80)
128
+ enrich_recent_spawn_details(
129
+ [mark],
130
+ [_timer_row()],
131
+ resources=resources,
132
+ fetcher=fetcher,
133
+ load_spawn_states=lambda _row, _resources: {
134
+ "SpawnPoint01": {"State": False, "Verified": True},
135
+ },
136
+ include_region_map=True,
137
+ )
138
+
139
+ assert [point.point_key for point in mark.spawn_points] == ["SpawnPoint02"]
140
+ assert mark.spawn_points[0].pixel_x is not None
141
+ assert mark.spawn_points[0].pixel_y is not None
142
+ assert mark.region_map is not None
143
+
144
+
145
+ def _spawn_detail_resources() -> BearResources:
146
+ return BearResources(
147
+ {
148
+ "DatabaseHunt": {
149
+ "The Forecaster": {
150
+ "Patch": "DT",
151
+ "Region": "Living Memory",
152
+ "Rank": 3,
153
+ "RespawnTimer": [46.0, 2.0],
154
+ },
155
+ },
156
+ "SpawnPoint": {
157
+ "The Forecaster": {
158
+ "Dimensions": [41, 41],
159
+ "DisplayPoints": 2,
160
+ "SpawnPoint01": [1, 1],
161
+ "SpawnPoint02": [21, 21],
162
+ },
163
+ },
164
+ "DataCenters": {},
165
+ },
166
+ )
167
+
168
+
169
+ def _timer_row() -> dict:
170
+ return {
171
+ "huntKey": "The Forecaster",
172
+ "huntName": "The Forecaster",
173
+ "worldName": "Test",
174
+ "lastDeathTime": 1000.0,
175
+ }
176
+
177
+
178
+ def _open_window_mark(*, progress_percent: float) -> HuntMarkRecord:
179
+ return HuntMarkRecord(
180
+ hunt_key="The Forecaster",
181
+ hunt_name="The Forecaster",
182
+ world_name="Test",
183
+ region="Living Memory",
184
+ patch="DT",
185
+ last_death_time=1000.0,
186
+ trigger_timer=build_timer_display(
187
+ kind=TimerKind.TRIGGER,
188
+ phase=SpawnWindowPhase.OPEN,
189
+ bar_color=TimerBarColor.SUCCESS,
190
+ counts_up=True,
191
+ progress_percent=progress_percent,
192
+ summary="test",
193
+ ),
194
+ )
@@ -1,3 +0,0 @@
1
- from __future__ import annotations
2
-
3
- DEFAULT_USER_AGENT = "ff14-the-hunt/0.0.9"
@@ -1,74 +0,0 @@
1
- import base64
2
- import struct
3
- import zlib
4
-
5
- from ff14_the_hunt.models import MapCoordinate
6
- from ff14_the_hunt.spawn_map.coordinates import with_pixel_coordinates
7
- from ff14_the_hunt.spawn_map.layout import layout_from_spawn_entry
8
- from ff14_the_hunt.spawn_map.png_meta import read_png_dimensions
9
-
10
-
11
- def _minimal_png(width: int, height: int) -> bytes:
12
- signature = b"\x89PNG\r\n\x1a\n"
13
- ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
14
- ihdr_chunk = (
15
- b"IHDR"
16
- + ihdr
17
- + struct.pack(">I", zlib.crc32(b"IHDR" + ihdr) & 0xFFFFFFFF)
18
- )
19
- ihdr_len = struct.pack(">I", len(ihdr))
20
- return signature + ihdr_len + ihdr_chunk
21
-
22
-
23
- def test_read_png_dimensions() -> None:
24
- data = _minimal_png(2048, 1024)
25
- assert read_png_dimensions(data) == (2048, 1024)
26
-
27
-
28
- def test_layout_from_spawn_entry() -> None:
29
- layout = layout_from_spawn_entry(
30
- {
31
- "Dimensions": [41, 41],
32
- "DisplayPoints": 8,
33
- "Version": 21,
34
- },
35
- )
36
- assert layout is not None
37
- assert layout.grid_scale == 41.0
38
- assert layout.display_points == 8
39
- assert layout.version == 21
40
-
41
-
42
- def test_with_pixel_coordinates() -> None:
43
- points = [
44
- MapCoordinate(point_key="SpawnPoint01", x=0.5, y=0.25),
45
- ]
46
- enriched = with_pixel_coordinates(points, image_width=2000, image_height=1000)
47
- assert enriched[0].pixel_x == 1000.0
48
- assert enriched[0].pixel_y == 250.0
49
-
50
-
51
- def test_region_map_image_url_encodes_special_characters() -> None:
52
- from ff14_the_hunt.spawn_map.region_fetch import region_map_image_url
53
-
54
- url = region_map_image_url("https://tracker.beartoolkit.com", "Yak T'el")
55
- assert (
56
- url
57
- == "https://tracker.beartoolkit.com/static/images/HuntRegions/Yak%20T%27el.png"
58
- )
59
- plain = region_map_image_url("https://tracker.beartoolkit.com", "Shaaloani")
60
- assert plain == "https://tracker.beartoolkit.com/static/images/HuntRegions/Shaaloani.png"
61
-
62
-
63
- def test_build_region_map_image_round_trip() -> None:
64
- from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher
65
- from ff14_the_hunt.spawn_map.region_image import build_region_map_image
66
-
67
- png = _minimal_png(100, 80)
68
- fetcher = RegionMapFetcher(site_root="https://tracker.beartoolkit.com")
69
- fetcher._cache["Shaaloani"] = png
70
- image = build_region_map_image(region="Shaaloani", fetcher=fetcher)
71
- assert image is not None
72
- assert image.width == 100
73
- assert image.height == 80
74
- assert base64.b64decode(image.data_base64) == png