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.
- python_library_ff14_the_hunt-0.0.0/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +13 -0
- python_library_ff14_the_hunt-0.0.0/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +22 -0
- python_library_ff14_the_hunt-0.0.0/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +25 -0
- python_library_ff14_the_hunt-0.0.0/.gitignore +13 -0
- python_library_ff14_the_hunt-0.0.0/PKG-INFO +7 -0
- python_library_ff14_the_hunt-0.0.0/README.md +50 -0
- python_library_ff14_the_hunt-0.0.0/example/__main__.py +69 -0
- python_library_ff14_the_hunt-0.0.0/example.bat +10 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/__init__.py +19 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/__init__.py +3 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/client.py +105 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/enrich.py +116 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/fate_timer.py +41 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/resources.py +53 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/spawn_points.py +60 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/bear_tracker/spawn_window.py +89 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/ff14_the_hunt.py +126 -0
- python_library_ff14_the_hunt-0.0.0/ff14_the_hunt/models.py +104 -0
- python_library_ff14_the_hunt-0.0.0/pyproject.toml +17 -0
- python_library_ff14_the_hunt-0.0.0/test.bat +9 -0
- python_library_ff14_the_hunt-0.0.0/tests/__init__.py +0 -0
- python_library_ff14_the_hunt-0.0.0/tests/test_spawn_window.py +16 -0
- 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,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,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,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"]
|
|
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
|