python-library-ff14-the-hunt 0.0.0__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 (23) hide show
  1. python_library_ff14_the_hunt-0.0.0/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +13 -0
  2. python_library_ff14_the_hunt-0.0.0/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +22 -0
  3. python_library_ff14_the_hunt-0.0.0/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +25 -0
  4. python_library_ff14_the_hunt-0.0.0/.gitignore +13 -0
  5. python_library_ff14_the_hunt-0.0.0/PKG-INFO +7 -0
  6. python_library_ff14_the_hunt-0.0.0/README.md +50 -0
  7. python_library_ff14_the_hunt-0.0.0/example/__main__.py +69 -0
  8. python_library_ff14_the_hunt-0.0.0/example.bat +10 -0
  9. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/__init__.py +19 -0
  10. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/__init__.py +3 -0
  11. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/client.py +105 -0
  12. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/enrich.py +116 -0
  13. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/fate_timer.py +41 -0
  14. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/resources.py +53 -0
  15. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/spawn_points.py +60 -0
  16. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/spawn_window.py +89 -0
  17. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/ff14_the_hunt.py +126 -0
  18. python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/models.py +104 -0
  19. python_library_ff14_the_hunt-0.0.0/pyproject.toml +17 -0
  20. python_library_ff14_the_hunt-0.0.0/test.bat +9 -0
  21. python_library_ff14_the_hunt-0.0.0/tests/__init__.py +0 -0
  22. python_library_ff14_the_hunt-0.0.0/tests/test_spawn_window.py +16 -0
  23. python_library_ff14_the_hunt-0.0.0/update.bat +9 -0
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: ff14-the-hunt-package-changelog
3
+ description: ff14_the_hunt 包:要求与决议;最新在上。
4
+ ---
5
+
6
+ # ff14_the_hunt · Changelog
7
+
8
+ (规则见 `~/.cursor/skills/agent-project-changelog/SKILL.md`。)
9
+
10
+ ## 2026-06-04
11
+
12
+ - **决议**:包目录由 **`ff14_world_boss`** 重命名为 **`ff14_the_hunt`**;PyPI 名 **`python-library-ff14-the-hunt`**;包级 skill 同步为 **`ff14_the_hunt-package-*`**。不考虑旧名兼容。
13
+ - **决议**:在 monorepo 初始化空包(初名 **ff14_world_boss**),并建立包级三件套。
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: ff14-the-hunt-package-design
3
+ description: >-
4
+ ff14_the_hunt 包:当前有效的设计意图;变更见 ff14_the_hunt-package-changelog。
5
+ ---
6
+
7
+ # ff14_the_hunt · 设计笔记
8
+
9
+ > 变更见 `.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md`;矛盾以 changelog 最新为准。
10
+
11
+ ## 设计意图
12
+
13
+ - **FF14 狩猎(The Hunt)**:本包用于最终幻想 XIV 狩猎相关信息的获取与处理(具体数据源与输出形态待定)。
14
+
15
+ ## 硬性要求
16
+
17
+ - 包目录名 **`ff14_the_hunt`**(snake_case);PyPI 名 **`python-library-ff14-the-hunt`**。
18
+ - 中文禁用词见 `~/.cursor/skills/forbidden-doc-comment-vocabulary/SKILL.md`。
19
+
20
+ ## 备忘与待定
21
+
22
+ - 数据源、提醒方式、对外 API 形态尚未确定。
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: ff14-the-hunt-package-preload
3
+ description: ff14_the_hunt 包:会话预加载;改本包源码前 Read。
4
+ ---
5
+
6
+ # ff14_the_hunt · 会话预加载
7
+
8
+ ## 初始化加载(Session preload)
9
+
10
+ 在 **`packages/ff14_the_hunt/`** 下改源码、测试或 `pyproject.toml` 时,按序 **Read**:
11
+
12
+ 1. `~/.cursor/skills/project-skill-manifest-policy/SKILL.md`
13
+ 2. `~/.cursor/skills/forbidden-doc-comment-vocabulary/SKILL.md`
14
+ 3. `~/.cursor/skills/markdown-authoring-zh/SKILL.md`
15
+ 4. `~/.cursor/skills/python-project-ai/SKILL.md`
16
+ 5. `~/.cursor/skills/python-doc-comments/SKILL.md`
17
+ 6. `~/.cursor/skills/agent-codegen-self-review/SKILL.md`
18
+ 7. `.cursor/skills/ff14_the_hunt-package-design/SKILL.md`
19
+ 8. `.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md`
20
+
21
+ 整库约定见仓库根 `.cursor/skills/python-library-session-manifest/SKILL.md`。
22
+
23
+ ## 用过的 skill(追加记录)
24
+
25
+ (初始为空。)
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ # example 抓取产物
13
+ example/output/
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-ff14-the-hunt
3
+ Version: 0.0.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: pydantic>=2.0
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,50 @@
1
+ # ff14_the_hunt
2
+
3
+ 从 [Bear Tracker](https://tracker.beartoolkit.com/timer) 拉取狩猎计时,解析触发窗/条件窗,并判断「刚刷新」。
4
+
5
+ ## 用法
6
+
7
+ ```python
8
+ from ff14_the_hunt import FF14TheHunt, HuntQueryFilter, HuntRankKind
9
+
10
+ hunt = FF14TheHunt()
11
+ marks = hunt.query_marks(
12
+ HuntQueryFilter(
13
+ data_centers=["猫小胖"],
14
+ worlds=["静语庄园"],
15
+ rank_kinds=[HuntRankKind.S, HuntRankKind.A],
16
+ patches=["DT"],
17
+ )
18
+ )
19
+ recent = hunt.recently_spawned(
20
+ HuntQueryFilter(
21
+ data_centers=["猫小胖"],
22
+ worlds=["静语庄园"],
23
+ rank_kinds=[HuntRankKind.S],
24
+ )
25
+ )
26
+ ```
27
+
28
+ ## 示例
29
+
30
+ ```bat
31
+ example.bat
32
+ ```
33
+
34
+ 或 `python -m example`。输出写入 `example/output/`(已列入 `.gitignore`)。
35
+
36
+ ## API 说明
37
+
38
+ | 站点接口 | 用途 |
39
+ | --- | --- |
40
+ | `POST /api/syncSession` | 狩猎库、刷点坐标、数据中心/世界列表 |
41
+ | `POST /api/lastDeathTimers` | 计时行;`RankType` 为 `aRank` / `sRank` / `fate` |
42
+ | `POST /api/querySpawnPoints` | 各刷点是否已激活(需 `LastDeath`) |
43
+
44
+ `QueryDeathTimers` 为世界名列表(由所选数据中心展开)。
45
+
46
+ 资料片筛选使用资源库中的 `Patch` 字段(7.0 金曦之遗辉 → `DT`)。
47
+
48
+ 触发时间窗算法与站点前端主计时列一致;带 `fateLastSeen` / `fateLastDeath` 的 7.0 S 链使用 FATE 条件计时。部分老式 S(如 Laideronnette)的天气条件窗尚未移植。
49
+
50
+ 「刚刷新」:触发窗开启后默认 15 分钟内,或 `lastMarkTime` 在默认 15 分钟内。
@@ -0,0 +1,69 @@
1
+ """示例:中国服务器 · 猫小胖 · 静语庄园 · 7.0(DT)· S 狩猎。
2
+
3
+ 资料片「金曦之遗辉」在 Bear Tracker 内对应 ``Patch=DT``。
4
+ 示例目标:DT 地图乌克帕夏的 Arch Aethereater Urqopacha(国服常称乌克帕夏 S 链)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from ff14_the_hunt import FF14TheHunt, HuntQueryFilter, HuntRankKind
13
+
14
+ DC_MOOGLE = "\u732b\u5c0f\u80d6"
15
+ WORLD_MANOR = "\u9759\u8bed\u5e84\u56ed"
16
+ PATCH_DAWNTRAIL = "DT"
17
+ EXAMPLE_HUNT = "Arch Aethereater Urqopacha"
18
+
19
+
20
+ def main() -> None:
21
+ hunt = FF14TheHunt()
22
+ print("中国区数据中心:", hunt.list_data_centers(region="CN"))
23
+
24
+ query = HuntQueryFilter(
25
+ data_centers=[DC_MOOGLE],
26
+ worlds=[WORLD_MANOR],
27
+ rank_kinds=[HuntRankKind.S],
28
+ patches=[PATCH_DAWNTRAIL],
29
+ hunt_keys=[EXAMPLE_HUNT],
30
+ )
31
+
32
+ marks = hunt.query_marks(query, include_spawn_states=True)
33
+ recent = hunt.recently_spawned(query)
34
+
35
+ out_dir = Path(__file__).resolve().parent / "output"
36
+ out_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ payload = {
39
+ "query": query.model_dump(),
40
+ "marks": [m.model_dump() for m in marks],
41
+ "recently_spawned": [m.model_dump() for m in recent],
42
+ }
43
+ out_path = out_dir / "bear_moogle_manor_dt_s.json"
44
+ out_path.write_text(
45
+ json.dumps(payload, ensure_ascii=False, indent=2),
46
+ encoding="utf-8",
47
+ )
48
+
49
+ print(f"写入 {out_path}")
50
+ for mark in marks:
51
+ print("---")
52
+ print(mark.hunt_name, mark.world_name, mark.region, mark.patch)
53
+ if mark.trigger_timer:
54
+ print(" 触发:", mark.trigger_timer.summary)
55
+ if mark.condition_timer:
56
+ print(" 条件:", mark.condition_timer.summary)
57
+ if mark.spawn_points:
58
+ pt = mark.spawn_points[0]
59
+ print(
60
+ f" 区域坐标(地图格点): {pt.point_key} "
61
+ f"grid=({pt.grid_x}, {pt.grid_y}) norm=({pt.x:.3f}, {pt.y:.3f})"
62
+ )
63
+ print(" 刚刷新:", mark.recently_spawned)
64
+
65
+ print(f"同条件下刚刷新 {len(recent)} 条")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
@@ -0,0 +1,10 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ call update.bat
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m pip install -e . -q
10
+ python -m example %*
@@ -0,0 +1,19 @@
1
+ from ff14_the_hunt.ff14_the_hunt import FF14TheHunt
2
+ from ff14_the_hunt.models import (
3
+ HuntMarkRecord,
4
+ HuntQueryFilter,
5
+ HuntRankKind,
6
+ MapCoordinate,
7
+ SpawnWindowPhase,
8
+ TimerDisplay,
9
+ )
10
+
11
+ __all__ = [
12
+ "FF14TheHunt",
13
+ "HuntMarkRecord",
14
+ "HuntQueryFilter",
15
+ "HuntRankKind",
16
+ "MapCoordinate",
17
+ "SpawnWindowPhase",
18
+ "TimerDisplay",
19
+ ]
@@ -0,0 +1,3 @@
1
+ from ff14_the_hunt.bear_tracker.client import BearTrackerClient
2
+
3
+ __all__ = ["BearTrackerClient"]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any
7
+
8
+ DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
9
+
10
+
11
+ class BearTrackerClient:
12
+ """Bear Tracker 站点同源 API 客户端(``/api/*``)。"""
13
+
14
+ def __init__(
15
+ self,
16
+ *,
17
+ base_url: str = DEFAULT_BASE_URL,
18
+ timeout_seconds: float = 120.0,
19
+ ) -> None:
20
+ self._base_url = base_url.rstrip("/")
21
+ self._timeout_seconds = timeout_seconds
22
+
23
+ def sync_session(self) -> dict[str, Any]:
24
+ """拉取会话与 ``resources``(含 DatabaseHunt、SpawnPoint、DataCenters)。"""
25
+ return self._post("/syncSession", {})
26
+
27
+ def last_death_timers(
28
+ self,
29
+ *,
30
+ world_names: list[str],
31
+ rank_type: str,
32
+ ) -> list[dict[str, Any]]:
33
+ """按世界列表查询死亡/计时记录。
34
+
35
+ Args:
36
+ world_names: 世界名列表(由数据中心展开或直接指定)。
37
+ rank_type: ``aRank``、``sRank`` 或 ``fate``。
38
+ """
39
+ payload = {
40
+ "QueryDeathTimers": world_names,
41
+ "RankType": rank_type,
42
+ }
43
+ data = self._post("/lastDeathTimers", payload)
44
+ timers = data.get("timers", [])
45
+ if isinstance(timers, dict):
46
+ return list(timers.values())
47
+ if isinstance(timers, list):
48
+ return timers
49
+ return []
50
+
51
+ def hunt_info(self, *, hunt_name: str, world_name: str) -> dict[str, Any]:
52
+ return self._post(
53
+ "/huntInfo",
54
+ {"HuntName": hunt_name, "WorldName": world_name},
55
+ )
56
+
57
+ def query_spawn_points(
58
+ self,
59
+ *,
60
+ hunt_name: str,
61
+ world_name: str,
62
+ last_death: float | None,
63
+ ) -> dict[str, Any]:
64
+ body: dict[str, Any] = {
65
+ "WorldName": world_name,
66
+ "HuntName": hunt_name,
67
+ "QuerySpawnPoint": "Query",
68
+ }
69
+ if last_death is not None:
70
+ body["LastDeath"] = last_death
71
+ result = self._post("/querySpawnPoints", body)
72
+ if isinstance(result, dict):
73
+ return result.get("data", result)
74
+ return {}
75
+
76
+ def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
77
+ url = f"{self._base_url}{path}"
78
+ encoded = json.dumps(body).encode("utf-8")
79
+ request = urllib.request.Request(
80
+ url,
81
+ data=encoded,
82
+ headers={
83
+ "User-Agent": "python-library-ff14-the-hunt",
84
+ "Content-Type": "application/json",
85
+ "Accept": "application/json",
86
+ "Origin": "https://tracker.beartoolkit.com",
87
+ "Referer": "https://tracker.beartoolkit.com/timer",
88
+ },
89
+ method="POST",
90
+ )
91
+ try:
92
+ with urllib.request.urlopen(
93
+ request,
94
+ timeout=self._timeout_seconds,
95
+ ) as response:
96
+ raw = response.read()
97
+ except urllib.error.HTTPError as exc:
98
+ detail = exc.read().decode("utf-8", errors="replace")
99
+ raise RuntimeError(
100
+ f"Bear Tracker API {path} failed: HTTP {exc.code}: {detail}"
101
+ ) from exc
102
+ parsed = json.loads(raw.decode("utf-8"))
103
+ if not isinstance(parsed, dict):
104
+ raise RuntimeError(f"unexpected response type from {path}")
105
+ return parsed
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from ff14_the_hunt.models import HuntMarkRecord, HuntQueryFilter, TimerDisplay
7
+
8
+ from ff14_the_hunt.bear_tracker.fate_timer import compute_fate_condition_timer
9
+ from ff14_the_hunt.bear_tracker.resources import BearResources
10
+ from ff14_the_hunt.bear_tracker.spawn_points import list_map_coordinates
11
+ from ff14_the_hunt.bear_tracker.spawn_window import (
12
+ compute_trigger_timer,
13
+ is_recently_in_window,
14
+ )
15
+
16
+
17
+ def _passes_filter(
18
+ *,
19
+ hunt_key: str,
20
+ meta: dict[str, Any],
21
+ world_name: str,
22
+ query: HuntQueryFilter,
23
+ ) -> bool:
24
+ if query.hunt_keys and hunt_key not in query.hunt_keys:
25
+ return False
26
+ patch = str(meta.get("Patch", ""))
27
+ if query.patches and patch not in query.patches:
28
+ return False
29
+ region = meta.get("Region", "")
30
+ if query.regions:
31
+ region_text = region if isinstance(region, str) else " ".join(region)
32
+ if not any(item in region_text for item in query.regions):
33
+ return False
34
+ if query.worlds and world_name not in query.worlds:
35
+ return False
36
+ return True
37
+
38
+
39
+ def build_hunt_record(
40
+ *,
41
+ timer_row: dict[str, Any],
42
+ resources: BearResources,
43
+ spawn_states: dict[str, Any] | None,
44
+ query: HuntQueryFilter,
45
+ now: float | None = None,
46
+ recent_grace_seconds: float = 900.0,
47
+ ) -> HuntMarkRecord | None:
48
+ hunt_key = str(timer_row.get("huntKey") or timer_row.get("huntName") or "")
49
+ world_name = str(timer_row.get("worldName") or "")
50
+ if not hunt_key or not world_name:
51
+ return None
52
+
53
+ meta = resources.hunt_meta(hunt_key)
54
+ if not _passes_filter(
55
+ hunt_key=hunt_key,
56
+ meta=meta,
57
+ world_name=world_name,
58
+ query=query,
59
+ ):
60
+ return None
61
+
62
+ is_maint = bool(timer_row.get("isMaint"))
63
+ timer_pair = meta.get("MaintTimer") if is_maint else meta.get("RespawnTimer")
64
+ last_death = timer_row.get("lastDeathTime")
65
+ last_mark = timer_row.get("lastMarkTime")
66
+ missing = float(timer_row.get("missingCounter") or 0.0)
67
+
68
+ trigger: TimerDisplay | None = None
69
+ if isinstance(timer_pair, (list, tuple)) and len(timer_pair) >= 2 and last_death:
70
+ trigger = compute_trigger_timer(
71
+ respawn_hours=(float(timer_pair[0]), float(timer_pair[1])),
72
+ last_death_time=float(last_death),
73
+ last_mark_time=float(last_mark) if last_mark else None,
74
+ missing_counter=missing,
75
+ now=now,
76
+ )
77
+
78
+ condition = compute_fate_condition_timer(
79
+ last_death_time=float(last_death or 0.0),
80
+ fate_last_seen=timer_row.get("fateLastSeen"),
81
+ fate_last_death=timer_row.get("fateLastDeath"),
82
+ now=now,
83
+ )
84
+
85
+ map_key = resources.spawn_map_key(hunt_key, meta)
86
+ spawn_entry = resources.spawn_point.get(map_key) if map_key else None
87
+ points = list_map_coordinates(spawn_entry, api_states=spawn_states)
88
+
89
+ recently = is_recently_in_window(trigger, grace_seconds=recent_grace_seconds)
90
+ if last_mark and now is not None:
91
+ if now - float(last_mark) <= recent_grace_seconds:
92
+ recently = True
93
+ elif last_mark:
94
+ if time.time() - float(last_mark) <= recent_grace_seconds:
95
+ recently = True
96
+
97
+ region = meta.get("Region", "")
98
+ return HuntMarkRecord(
99
+ hunt_key=hunt_key,
100
+ hunt_name=str(timer_row.get("huntName") or hunt_key),
101
+ world_name=world_name,
102
+ region=region,
103
+ patch=str(meta.get("Patch", "")),
104
+ rank=meta.get("Rank"),
105
+ last_death_time=float(last_death) if last_death else None,
106
+ last_mark_time=float(last_mark) if last_mark else None,
107
+ missing_counter=missing,
108
+ is_maintenance=is_maint,
109
+ fate_last_seen=timer_row.get("fateLastSeen"),
110
+ fate_last_death=timer_row.get("fateLastDeath"),
111
+ trigger_timer=trigger,
112
+ condition_timer=condition,
113
+ spawn_points=points,
114
+ recently_spawned=recently,
115
+ raw_timer=dict(timer_row),
116
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from ff14_the_hunt.models import SpawnWindowPhase, TimerDisplay
6
+
7
+ from ff14_the_hunt.bear_tracker.spawn_window import _format_duration
8
+
9
+
10
+ def compute_fate_condition_timer(
11
+ *,
12
+ last_death_time: float,
13
+ fate_last_seen: float | None,
14
+ fate_last_death: float | None,
15
+ now: float | None = None,
16
+ ) -> TimerDisplay | None:
17
+ """7.0 部分 S 猎(如 Arch Aethereater)使用的 FATE 链条件计时(组件 ``lo`` 前半)。"""
18
+ if fate_last_seen is None or fate_last_death is None:
19
+ return None
20
+ if now is None:
21
+ now = time.time()
22
+ now_ms = now * 1000.0
23
+ seen_ms = fate_last_seen * 1000.0
24
+ death_ms = fate_last_death * 1000.0
25
+
26
+ if death_ms > seen_ms:
27
+ delta = now_ms - death_ms
28
+ return TimerDisplay(
29
+ label="condition",
30
+ phase=SpawnWindowPhase.CLOSED,
31
+ elapsed_seconds=delta / 1000.0,
32
+ summary=f"FATE 已结束 {_format_duration(delta / 1000.0)}",
33
+ )
34
+
35
+ delta = now_ms - seen_ms
36
+ return TimerDisplay(
37
+ label="condition",
38
+ phase=SpawnWindowPhase.OPEN,
39
+ elapsed_seconds=delta / 1000.0,
40
+ summary=f"FATE 进行中 {_format_duration(delta / 1000.0)}",
41
+ )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class BearResources:
7
+ """``syncSession`` 返回的 resources 只读视图。"""
8
+
9
+ def __init__(self, resources: dict[str, Any]) -> None:
10
+ self._resources = resources
11
+
12
+ @property
13
+ def database_hunt(self) -> dict[str, Any]:
14
+ return self._resources.get("DatabaseHunt", {})
15
+
16
+ @property
17
+ def spawn_point(self) -> dict[str, Any]:
18
+ return self._resources.get("SpawnPoint", {})
19
+
20
+ @property
21
+ def data_centers(self) -> dict[str, Any]:
22
+ return self._resources.get("DataCenters", {})
23
+
24
+ def worlds_for_data_centers(self, data_center_names: list[str]) -> list[str]:
25
+ worlds: list[str] = []
26
+ for name in data_center_names:
27
+ info = self.data_centers.get(name)
28
+ if not info:
29
+ continue
30
+ for world in info.get("Names", []):
31
+ if world not in worlds:
32
+ worlds.append(world)
33
+ return worlds
34
+
35
+ def hunt_meta(self, hunt_key: str) -> dict[str, Any]:
36
+ meta = self.database_hunt.get(hunt_key)
37
+ if meta:
38
+ return meta
39
+ trimmed = hunt_key[:-2] if len(hunt_key) > 2 else hunt_key
40
+ return self.database_hunt.get(trimmed, {})
41
+
42
+ def spawn_map_key(self, hunt_key: str, meta: dict[str, Any]) -> str | None:
43
+ patch = meta.get("Patch", "")
44
+ region = meta.get("Region", "")
45
+ if patch == "DT" and hunt_key.startswith("Arch Aethereater "):
46
+ return f"Arch Aethereater {region}"
47
+ if patch == "EW" and hunt_key.startswith("Ker "):
48
+ return hunt_key
49
+ if patch == "ShB" and hunt_key.startswith("Forgiven Rebellion "):
50
+ return hunt_key
51
+ if hunt_key in self.spawn_point:
52
+ return hunt_key
53
+ return None
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from ff14_the_hunt.models import MapCoordinate
7
+
8
+
9
+ def list_map_coordinates(
10
+ spawn_entry: dict[str, Any] | None,
11
+ *,
12
+ api_states: dict[str, Any] | None = None,
13
+ ) -> list[MapCoordinate]:
14
+ """从 ``resources.SpawnPoint`` 条目解析触发生点坐标。
15
+
16
+ Args:
17
+ spawn_entry: ``syncSession`` 内 ``SpawnPoint[huntKey]``。
18
+ api_states: ``querySpawnPoints`` 返回的各点状态(可选)。
19
+ """
20
+ if not spawn_entry:
21
+ return []
22
+ dimensions = spawn_entry.get("Dimensions") or [41, 41]
23
+ scale = float(dimensions[0]) if dimensions else 41.0
24
+ display_count = int(spawn_entry.get("DisplayPoints", 0))
25
+ coordinates: list[MapCoordinate] = []
26
+ point_items = sorted(
27
+ (
28
+ (key, value)
29
+ for key, value in spawn_entry.items()
30
+ if re.match(r"^SpawnPoint\d+$", key)
31
+ ),
32
+ key=lambda item: item[0],
33
+ )
34
+ for index, (key, raw) in enumerate(point_items):
35
+ if display_count and index >= display_count:
36
+ break
37
+ if not isinstance(raw, (list, tuple)) or len(raw) < 2:
38
+ continue
39
+ grid_x = float(raw[0])
40
+ grid_y = float(raw[1])
41
+ norm_x = (grid_x - 1.0) / scale
42
+ norm_y = (grid_y - 1.0) / scale
43
+ state = None
44
+ if api_states and key in api_states:
45
+ entry = api_states[key]
46
+ if isinstance(entry, dict):
47
+ state_val = entry.get("State")
48
+ if state_val is not None:
49
+ state = bool(state_val)
50
+ coordinates.append(
51
+ MapCoordinate(
52
+ point_key=key,
53
+ x=norm_x,
54
+ y=norm_y,
55
+ grid_x=grid_x,
56
+ grid_y=grid_y,
57
+ active=state,
58
+ )
59
+ )
60
+ return coordinates
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from ff14_the_hunt.models import SpawnWindowPhase, TimerDisplay
6
+
7
+
8
+ def _format_duration(seconds: float) -> str:
9
+ total = max(0, int(seconds))
10
+ hours = total // 3600
11
+ minutes = (total % 3600) // 60
12
+ return f"{hours:02d}:{minutes:02d}"
13
+
14
+
15
+ def compute_trigger_timer(
16
+ *,
17
+ respawn_hours: tuple[float, float],
18
+ last_death_time: float,
19
+ last_mark_time: float | None = None,
20
+ missing_counter: float = 0.0,
21
+ now: float | None = None,
22
+ ) -> TimerDisplay:
23
+ """复刻站点主计时列(组件 ``io``)的触发时间窗逻辑。
24
+
25
+ Args:
26
+ respawn_hours: ``(开窗延迟小时, 窗宽小时)``,来自 RespawnTimer 或 MaintTimer。
27
+ last_death_time: 上次死亡 Unix 秒。
28
+ last_mark_time: 有人标记发现时的时间;会收窄/平移开窗区间。
29
+ missing_counter: 与站点一致的失踪计数加权。
30
+ now: 当前 Unix 秒,默认 ``time.time()``。
31
+ """
32
+ if now is None:
33
+ now = time.time()
34
+ start_hours, window_hours = respawn_hours
35
+ now_ms = now * 1000.0
36
+ death_ms = last_death_time * 1000.0
37
+
38
+ if last_mark_time is not None and last_mark_time > 0:
39
+ mark_ms = last_mark_time * 1000.0
40
+ open_ms = (missing_counter + 1.0) * start_hours * 3_600_000.0 + death_ms
41
+ close_ms = window_hours * 3_600_000.0 + start_hours * 3_600_000.0 + mark_ms
42
+ else:
43
+ open_ms = start_hours * 3_600_000.0 + death_ms
44
+ close_ms = open_ms + window_hours * 3_600_000.0
45
+
46
+ if now_ms < open_ms:
47
+ remaining = (open_ms - now_ms) / 1000.0
48
+ return TimerDisplay(
49
+ label="trigger",
50
+ phase=SpawnWindowPhase.ALMOST_OPEN,
51
+ remaining_seconds=remaining,
52
+ summary=f"距离开窗 {_format_duration(remaining)}",
53
+ )
54
+
55
+ if now_ms < close_ms:
56
+ elapsed = (now_ms - open_ms) / 1000.0
57
+ span = (close_ms - open_ms) / 1000.0
58
+ progress = (elapsed / span * 100.0) if span > 0 else 0.0
59
+ return TimerDisplay(
60
+ label="trigger",
61
+ phase=SpawnWindowPhase.OPEN,
62
+ elapsed_seconds=elapsed,
63
+ progress_percent=min(progress, 999.0),
64
+ summary=f"已开窗 {_format_duration(elapsed)}({progress:.0f}%)",
65
+ )
66
+
67
+ elapsed_since_cap = (now_ms - close_ms) / 1000.0
68
+ return TimerDisplay(
69
+ label="trigger",
70
+ phase=SpawnWindowPhase.CAPPED,
71
+ elapsed_seconds=elapsed_since_cap,
72
+ summary=f"已强制(cap) {_format_duration(elapsed_since_cap)}",
73
+ )
74
+
75
+
76
+ def is_window_open(timer: TimerDisplay | None) -> bool:
77
+ return timer is not None and timer.phase == SpawnWindowPhase.OPEN
78
+
79
+
80
+ def is_recently_in_window(
81
+ timer: TimerDisplay | None,
82
+ *,
83
+ grace_seconds: float = 900.0,
84
+ ) -> bool:
85
+ if timer is None or timer.phase != SpawnWindowPhase.OPEN:
86
+ return False
87
+ if timer.elapsed_seconds is None:
88
+ return False
89
+ return timer.elapsed_seconds <= grace_seconds
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from ff14_the_hunt.bear_tracker.client import BearTrackerClient
4
+ from ff14_the_hunt.bear_tracker.enrich import build_hunt_record
5
+ from ff14_the_hunt.bear_tracker.resources import BearResources
6
+ from ff14_the_hunt.models import HuntMarkRecord, HuntQueryFilter, HuntRankKind
7
+
8
+
9
+ class FF14TheHunt:
10
+ """FF14 狩猎追踪门面:当前对接 [Bear Tracker](https://tracker.beartoolkit.com/timer)。"""
11
+
12
+ def __init__(
13
+ self,
14
+ *,
15
+ base_url: str | None = None,
16
+ timeout_seconds: float = 120.0,
17
+ ) -> None:
18
+ kwargs: dict[str, float | str] = {"timeout_seconds": timeout_seconds}
19
+ if base_url is not None:
20
+ kwargs["base_url"] = base_url
21
+ self._client = BearTrackerClient(**kwargs)
22
+ self._resources: BearResources | None = None
23
+
24
+ @property
25
+ def client(self) -> BearTrackerClient:
26
+ return self._client
27
+
28
+ def ensure_resources(self) -> BearResources:
29
+ if self._resources is None:
30
+ session = self._client.sync_session()
31
+ raw = session.get("resources", {})
32
+ self._resources = BearResources(raw)
33
+ return self._resources
34
+
35
+ def list_data_centers(self, *, region: str | None = None) -> list[str]:
36
+ """列出数据中心名;``region='CN'`` 仅中国区。"""
37
+ resources = self.ensure_resources()
38
+ names: list[str] = []
39
+ for name, info in resources.data_centers.items():
40
+ if region is not None and info.get("Region") != region:
41
+ continue
42
+ names.append(name)
43
+ return sorted(names)
44
+
45
+ def list_worlds(self, data_centers: list[str]) -> list[str]:
46
+ resources = self.ensure_resources()
47
+ return resources.worlds_for_data_centers(data_centers)
48
+
49
+ def query_marks(
50
+ self,
51
+ query: HuntQueryFilter,
52
+ *,
53
+ include_spawn_states: bool = False,
54
+ recent_grace_seconds: float = 900.0,
55
+ ) -> list[HuntMarkRecord]:
56
+ """按筛选条件查询并解析狩猎计时。
57
+
58
+ Args:
59
+ query: 数据中心、世界、Rank、资料片等筛选。
60
+ include_spawn_states: 为 True 时对每条记录请求 ``querySpawnPoints``(较慢)。
61
+ recent_grace_seconds: 开窗或标记后多少秒内视为「刚刷新」。
62
+ """
63
+ resources = self.ensure_resources()
64
+ worlds = list(query.worlds)
65
+ if not worlds:
66
+ worlds = resources.worlds_for_data_centers(query.data_centers)
67
+ if not worlds:
68
+ return []
69
+
70
+ rows: list[dict] = []
71
+ for rank in query.rank_kinds:
72
+ rows.extend(
73
+ self._client.last_death_timers(
74
+ world_names=worlds,
75
+ rank_type=rank.value,
76
+ )
77
+ )
78
+
79
+ records: list[HuntMarkRecord] = []
80
+ for row in rows:
81
+ spawn_states = None
82
+ if include_spawn_states:
83
+ spawn_states = self._load_spawn_states(row, resources)
84
+ record = build_hunt_record(
85
+ timer_row=row,
86
+ resources=resources,
87
+ spawn_states=spawn_states,
88
+ query=query,
89
+ recent_grace_seconds=recent_grace_seconds,
90
+ )
91
+ if record is not None:
92
+ records.append(record)
93
+ return records
94
+
95
+ def recently_spawned(
96
+ self,
97
+ query: HuntQueryFilter,
98
+ *,
99
+ recent_grace_seconds: float = 900.0,
100
+ ) -> list[HuntMarkRecord]:
101
+ marks = self.query_marks(
102
+ query,
103
+ recent_grace_seconds=recent_grace_seconds,
104
+ )
105
+ return [mark for mark in marks if mark.recently_spawned]
106
+
107
+ def _load_spawn_states(
108
+ self,
109
+ row: dict,
110
+ resources: BearResources,
111
+ ) -> dict | None:
112
+ hunt_key = str(row.get("huntKey") or "")
113
+ world_name = str(row.get("worldName") or "")
114
+ if not hunt_key or not world_name:
115
+ return None
116
+ meta = resources.hunt_meta(hunt_key)
117
+ last_death = row.get("lastMarkTime") or row.get("lastDeathTime")
118
+ try:
119
+ states = self._client.query_spawn_points(
120
+ hunt_name=hunt_key,
121
+ world_name=world_name,
122
+ last_death=float(last_death) if last_death else None,
123
+ )
124
+ except RuntimeError:
125
+ return None
126
+ return states if isinstance(states, dict) else None
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class HuntRankKind(str, Enum):
10
+ """Bear Tracker ``lastDeathTimers`` 的 ``RankType`` 取值。"""
11
+
12
+ A = "aRank"
13
+ S = "sRank"
14
+ FATE = "fate"
15
+
16
+
17
+ class SpawnWindowPhase(str, Enum):
18
+ """与站点主计时条颜色/文案一致的开窗阶段。"""
19
+
20
+ CLOSED = "closed"
21
+ ALMOST_OPEN = "almost_open"
22
+ OPEN = "open"
23
+ CAPPED = "capped"
24
+
25
+
26
+ class MapCoordinate(BaseModel):
27
+ """地图格点坐标(与站点触发的地图一致,非游戏内绝对坐标)。"""
28
+
29
+ model_config = ConfigDict(extra="ignore")
30
+
31
+ point_key: str = Field(description="SpawnPoint 键名,例如 SpawnPoint01")
32
+ x: float = Field(description="地图 X,已按站点公式归一化")
33
+ y: float = Field(description="地图 Y,已按站点公式归一化")
34
+ grid_x: float | None = Field(default=None, description="原始格点 X")
35
+ grid_y: float | None = Field(default=None, description="原始格点 Y")
36
+ active: bool | None = Field(default=None, description="querySpawnPoints 返回的存活点位状态")
37
+
38
+
39
+ class TimerDisplay(BaseModel):
40
+ """单条计时展示(触发窗或条件窗)。"""
41
+
42
+ model_config = ConfigDict(extra="ignore")
43
+
44
+ label: str = Field(description="例如 trigger / condition / fate")
45
+ phase: SpawnWindowPhase | None = Field(default=None)
46
+ elapsed_seconds: float | None = Field(default=None)
47
+ remaining_seconds: float | None = Field(default=None)
48
+ progress_percent: float | None = Field(default=None)
49
+ summary: str = Field(default="", description="人类可读简述")
50
+
51
+
52
+ class HuntMarkRecord(BaseModel):
53
+ """单条狩猎计时记录(合并 API 与资源库后的视图)。"""
54
+
55
+ model_config = ConfigDict(extra="ignore")
56
+
57
+ hunt_key: str
58
+ hunt_name: str
59
+ world_name: str
60
+ region: str | list[str] = ""
61
+ patch: str = ""
62
+ rank: int | None = None
63
+ last_death_time: float | None = None
64
+ last_mark_time: float | None = None
65
+ missing_counter: float = 0.0
66
+ is_maintenance: bool = False
67
+ fate_last_seen: float | None = None
68
+ fate_last_death: float | None = None
69
+ trigger_timer: TimerDisplay | None = None
70
+ condition_timer: TimerDisplay | None = None
71
+ spawn_points: list[MapCoordinate] = Field(default_factory=list)
72
+ recently_spawned: bool = False
73
+ raw_timer: dict[str, Any] = Field(default_factory=dict)
74
+
75
+
76
+ class HuntQueryFilter(BaseModel):
77
+ """查询 Bear Tracker 时的筛选条件。"""
78
+
79
+ model_config = ConfigDict(extra="forbid")
80
+
81
+ data_centers: list[str] = Field(
82
+ default_factory=list,
83
+ description="中国区为猫小胖、莫古力等数据中心名;国际区为 Aether 等",
84
+ )
85
+ worlds: list[str] = Field(
86
+ default_factory=list,
87
+ description="世界名;为空时由 data_centers 展开全部世界",
88
+ )
89
+ rank_kinds: list[HuntRankKind] = Field(
90
+ default_factory=lambda: [HuntRankKind.S],
91
+ description="A / S / FATE,可多选",
92
+ )
93
+ patches: list[str] = Field(
94
+ default_factory=list,
95
+ description="资料片缩写:ARR、HW、ShB、EW、DT 等;空表示不过滤",
96
+ )
97
+ hunt_keys: list[str] = Field(
98
+ default_factory=list,
99
+ description="限定 huntKey;空表示不过滤",
100
+ )
101
+ regions: list[str] = Field(
102
+ default_factory=list,
103
+ description="地图区域名;空表示不过滤",
104
+ )
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "python-library-ff14-the-hunt"
7
+ version = "0.0.0"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "pydantic>=2.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8.0"]
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["ff14_the_hunt"]
@@ -0,0 +1,9 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ call update.bat
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m unittest discover -s tests -p "test_*.py"
File without changes
@@ -0,0 +1,16 @@
1
+ from ff14_the_hunt.bear_tracker.spawn_window import (
2
+ SpawnWindowPhase,
3
+ compute_trigger_timer,
4
+ )
5
+
6
+
7
+ def test_trigger_window_open() -> None:
8
+ last_death = 1_000_000.0
9
+ timer = compute_trigger_timer(
10
+ respawn_hours=(1.0, 2.0),
11
+ last_death_time=last_death,
12
+ now=last_death + 3600 + 60,
13
+ )
14
+ assert timer.phase == SpawnWindowPhase.OPEN
15
+ assert timer.progress_percent is not None
16
+ assert timer.progress_percent > 0
@@ -0,0 +1,9 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ python -m venv .venv
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m pip install -e .