Add Time Tool
This commit is contained in:
parent
ae9b31a23e
commit
14e6222cf1
|
|
@ -32,6 +32,20 @@ from typing import Optional
|
||||||
|
|
||||||
from wwn_mechanics import calc_hp_max
|
from wwn_mechanics import calc_hp_max
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
scripts_path = Path(__file__).resolve().parent.parent
|
||||||
|
if str(scripts_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(scripts_path))
|
||||||
|
|
||||||
|
from vault_utils import (
|
||||||
|
vault_query,
|
||||||
|
write_file as write_faction, # алиас для обратной совместимости
|
||||||
|
create_file as create_event_file, # алиас для обратной совместимости
|
||||||
|
normalize_wikilink,
|
||||||
|
resolve_wikilink,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Настройки
|
# Настройки
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -42,89 +56,6 @@ VAULT_QUERY_TIMEOUT = 12 # секунд
|
||||||
# По WWN: один faction turn ≈ 1 месяц ≈ 100 миль перемещения
|
# По WWN: один faction turn ≈ 1 месяц ≈ 100 миль перемещения
|
||||||
WWN_MILES_PER_TURN = 100
|
WWN_MILES_PER_TURN = 100
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Базовый клиент
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def vault_query(query_type: str, payload: dict) -> any:
|
|
||||||
"""
|
|
||||||
Отправляет запрос к Obsidian vault через бэкенд-прокси.
|
|
||||||
|
|
||||||
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
|
|
||||||
Бросает RuntimeError при недоступности бэкенда или timeout.
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
query_type: тип операции
|
|
||||||
payload: параметры операции
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
resp = http_requests.post(
|
|
||||||
f"{BACKEND_URL}/vault/query",
|
|
||||||
json={"type": query_type, "payload": payload},
|
|
||||||
timeout=VAULT_QUERY_TIMEOUT,
|
|
||||||
proxies={"http": None, "https": None}
|
|
||||||
)
|
|
||||||
except http_requests.exceptions.ConnectionError as e:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Бэкенд недоступен ({BACKEND_URL}). "
|
|
||||||
"Убедитесь что Flask-сервер запущен."
|
|
||||||
)
|
|
||||||
except http_requests.exceptions.Timeout:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"vault_query timeout после {VAULT_QUERY_TIMEOUT}с. "
|
|
||||||
"Возможно Obsidian не подключён к WebSocket."
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 504:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Obsidian не ответил на запрос vault за 10 секунд. "
|
|
||||||
"Проверьте что плагин LLM Agent запущен и подключён."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not resp.ok:
|
|
||||||
try:
|
|
||||||
err = resp.json().get("error", resp.text)
|
|
||||||
except Exception:
|
|
||||||
err = resp.text
|
|
||||||
raise RuntimeError(f"vault_query ошибка {resp.status_code}: {err}")
|
|
||||||
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Нормализация wikilinks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def normalize_wikilink(s: str) -> str:
|
|
||||||
"""
|
|
||||||
Приводит wikilink к нижнему регистру без скобок и алиасов.
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
'[[Waterdeep]]' → 'waterdeep'
|
|
||||||
'[[Waterdeep|City]]' → 'waterdeep'
|
|
||||||
'Waterdeep' → 'waterdeep'
|
|
||||||
'' → ''
|
|
||||||
"""
|
|
||||||
if not s:
|
|
||||||
return ''
|
|
||||||
return re.sub(r'^\[\[|\]\]$', '', s).split('|')[0].strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_wikilink(name: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Резолвит wikilink в путь к файлу через Obsidian metadataCache.
|
|
||||||
Знает об aliases.
|
|
||||||
|
|
||||||
Принимает '[[Waterdeep]]', '[[Waterdeep|City]]' или просто 'Waterdeep'.
|
|
||||||
Возвращает путь вида 'TTRPG/Locations/Waterdeep.md' или None.
|
|
||||||
"""
|
|
||||||
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
|
|
||||||
result = vault_query("resolve_wikilink", {"name": original_clean})
|
|
||||||
# vault_query возвращает строку-путь или None напрямую
|
|
||||||
return result if isinstance(result, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Фракции
|
# Фракции
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -203,22 +134,22 @@ def get_factions_in_location(all_factions: list[dict], locations_wikilinks: list
|
||||||
result = []
|
result = []
|
||||||
for faction in all_factions:
|
for faction in all_factions:
|
||||||
fm = faction.get("frontmatter", {})
|
fm = faction.get("frontmatter", {})
|
||||||
|
|
||||||
# Проверка списка locations фракции
|
# Проверка списка locations фракции
|
||||||
locs = fm.get("locations", [])
|
locs = fm.get("locations", [])
|
||||||
if isinstance(locs, str): locs = [locs]
|
if isinstance(locs, str): locs = [locs]
|
||||||
normalized_locs = [normalize_wikilink(l) for l in locs if isinstance(l, str)]
|
normalized_locs = [normalize_wikilink(l) for l in locs if isinstance(l, str)]
|
||||||
|
|
||||||
# Проверка локаций активов
|
# Проверка локаций активов
|
||||||
asset_locs = [
|
asset_locs = [
|
||||||
normalize_wikilink(a.get("location", ""))
|
normalize_wikilink(a.get("location", ""))
|
||||||
for a in fm.get("assets", []) if isinstance(a, dict)
|
for a in fm.get("assets", []) if isinstance(a, dict)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Если фракция есть хотя бы в одной из целевых локаций
|
# Если фракция есть хотя бы в одной из целевых локаций
|
||||||
if any(t in normalized_locs or t in asset_locs for t in targets):
|
if any(t in normalized_locs or t in asset_locs for t in targets):
|
||||||
result.append(faction)
|
result.append(faction)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_assets_in_location(
|
def get_assets_in_location(
|
||||||
|
|
@ -231,38 +162,20 @@ def get_assets_in_location(
|
||||||
"""
|
"""
|
||||||
fm = faction_data.get("frontmatter", faction_data)
|
fm = faction_data.get("frontmatter", faction_data)
|
||||||
assets = fm.get("assets", [])
|
assets = fm.get("assets", [])
|
||||||
|
|
||||||
if not locations_wikilinks:
|
if not locations_wikilinks:
|
||||||
return [a for a in assets if isinstance(a, dict)]
|
return [a for a in assets if isinstance(a, dict)]
|
||||||
|
|
||||||
targets = [normalize_wikilink(loc) for loc in locations_wikilinks if loc]
|
targets = [normalize_wikilink(loc) for loc in locations_wikilinks if loc]
|
||||||
if not targets:
|
if not targets:
|
||||||
return [a for a in assets if isinstance(a, dict)]
|
return [a for a in assets if isinstance(a, dict)]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
a for a in assets
|
a for a in assets
|
||||||
if isinstance(a, dict) and normalize_wikilink(a.get("location", "")) in targets
|
if isinstance(a, dict) and normalize_wikilink(a.get("location", "")) in targets
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def write_faction(file_path: str, frontmatter: dict, body: str) -> bool:
|
|
||||||
"""
|
|
||||||
Сохраняет изменённый frontmatter фракции обратно в Obsidian.
|
|
||||||
|
|
||||||
file_path — путь вида 'Фракции/Cult_of_the_Dragon.md'
|
|
||||||
frontmatter — словарь с обновлёнными данными
|
|
||||||
body — тело файла (лор-текст), берётся из get_faction() без изменений
|
|
||||||
"""
|
|
||||||
result = vault_query("write_frontmatter", {
|
|
||||||
"path": file_path,
|
|
||||||
"frontmatter": frontmatter,
|
|
||||||
"body": body
|
|
||||||
})
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result.get("success", False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Локации и расстояния
|
# Локации и расстояния
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -403,22 +316,6 @@ def get_recent_events(
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def create_event_file(file_path: str, content: str) -> bool:
|
|
||||||
"""
|
|
||||||
Создаёт файл события в Obsidian vault.
|
|
||||||
|
|
||||||
Если файл уже существует — перезаписывает.
|
|
||||||
Промежуточные папки создаются автоматически.
|
|
||||||
"""
|
|
||||||
result = vault_query("create_file", {
|
|
||||||
"path": file_path,
|
|
||||||
"content": content
|
|
||||||
})
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result.get("success", False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Кубики (WWN-механика)
|
# Кубики (WWN-механика)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -619,11 +516,12 @@ def normalize_faction_frontmatter(fm: dict) -> dict:
|
||||||
# Скалярные дефолты
|
# Скалярные дефолты
|
||||||
for key, default in FACTION_DEFAULTS.items():
|
for key, default in FACTION_DEFAULTS.items():
|
||||||
if fm.get(key) is None:
|
if fm.get(key) is None:
|
||||||
fm[key] = default.copy() if isinstance(default, (list, dict)) else default
|
fm[key] = default.copy() if isinstance(default,
|
||||||
|
(list, dict)) else default
|
||||||
|
|
||||||
# Списочные поля (нарративные) — чистим от пустых заглушек
|
# Списочные поля (нарративные) — чистим от пустых заглушек
|
||||||
for list_field in ("locations", "motivations", "headquarters",
|
for list_field in ("locations", "motivations", "headquarters", "leaders",
|
||||||
"leaders", "enemies"):
|
"enemies"):
|
||||||
fm[list_field] = _clean_list_field(fm.get(list_field))
|
fm[list_field] = _clean_list_field(fm.get(list_field))
|
||||||
|
|
||||||
# tags_listen / faction_tags тоже чистим
|
# tags_listen / faction_tags тоже чистим
|
||||||
|
|
@ -649,4 +547,4 @@ def normalize_faction_frontmatter(fm: dict) -> dict:
|
||||||
fm["enemies"] = [str(x).strip() for x in fm["enemies"] if x]
|
fm["enemies"] = [str(x).strip() for x in fm["enemies"] if x]
|
||||||
fm["allies"] = [str(x).strip() for x in fm["allies"] if x]
|
fm["allies"] = [str(x).strip() for x in fm["allies"] if x]
|
||||||
|
|
||||||
return fm
|
return fm
|
||||||
|
|
|
||||||
58
time/get_campaign_time.py
Normal file
58
time/get_campaign_time.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""
|
||||||
|
get_campaign_time.py — получение текущего игрового времени.
|
||||||
|
|
||||||
|
Экспортирует функцию get_campaign_time(debug_description: str) -> str,
|
||||||
|
которую вызывает тул с фронта без параметров.
|
||||||
|
|
||||||
|
Возвращает строку в формате Markdown с текущей датой, часом и списком праздников на неделю.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from time_utils import (
|
||||||
|
get_campaign_time as get_time,
|
||||||
|
MONTH_NAMES_RU,
|
||||||
|
get_week_holidays,
|
||||||
|
get_holiday,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(year: int, month: int, day: int, hour: int) -> str:
|
||||||
|
month_name = MONTH_NAMES_RU.get(month, str(month))
|
||||||
|
return f"{day} {month_name} {year} DR, {hour:02d}:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_holidays_section(year: int, month: int, day: int) -> str:
|
||||||
|
holidays = get_week_holidays(year, month, day)
|
||||||
|
if not holidays:
|
||||||
|
return "📅 **Ближайшие праздники:** нет"
|
||||||
|
lines = ["📅 **Ближайшие праздники:**"]
|
||||||
|
for m, d, desc, when in holidays:
|
||||||
|
month_name = MONTH_NAMES_RU.get(m, str(m))
|
||||||
|
lines.append(f"- **{when}** ({d} {month_name}): {desc}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_campaign_time(debug_description: str) -> str:
|
||||||
|
current = get_time()
|
||||||
|
time_str = _format_time(current["year"], current["month"], current["day"], current["hour"])
|
||||||
|
|
||||||
|
today_holiday = get_holiday(current["month"], current["day"], current["year"])
|
||||||
|
today_line = f"🎉 **Сегодня:** {today_holiday}" if today_holiday else ""
|
||||||
|
|
||||||
|
holidays_section = _format_holidays_section(
|
||||||
|
current["year"],
|
||||||
|
current["month"],
|
||||||
|
current["day"]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = (
|
||||||
|
f"### 🕰️ Текущее время\n"
|
||||||
|
f"**Дата:** {time_str}\n"
|
||||||
|
)
|
||||||
|
if today_line:
|
||||||
|
result += f"{today_line}\n"
|
||||||
|
result += f"\n{holidays_section}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(get_campaign_time("тест"))
|
||||||
223
time/set_campaign_time.py
Normal file
223
time/set_campaign_time.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""
|
||||||
|
set_campaign_time.py — изменение игрового времени.
|
||||||
|
|
||||||
|
Экспортирует функцию set_campaign_time(debug_description, requests), которую вызывает тул с фронта.
|
||||||
|
|
||||||
|
requests — список объектов вида:
|
||||||
|
{
|
||||||
|
"mode": "absolute" | "shift", # режим: absolute – установка, shift – сдвиг (default "shift")
|
||||||
|
"year": 1492, # при absolute – год, при shift – сдвиг лет
|
||||||
|
"month": 1, # при absolute – месяц (число 1-12 или русское название), при shift – сдвиг месяцев (число)
|
||||||
|
"day": 1, # при absolute – день, при shift – сдвиг дней
|
||||||
|
"hour": 0, # при absolute – час, при shift – сдвиг часов
|
||||||
|
}
|
||||||
|
|
||||||
|
При mode="absolute" можно передать только часть полей, остальные останутся без изменений.
|
||||||
|
Поле month при mode="absolute" принимает:
|
||||||
|
- целое число от 1 до 12
|
||||||
|
- строку, содержащую число (например "3")
|
||||||
|
- русское название месяца (регистр не важен):
|
||||||
|
1: Хаммер
|
||||||
|
2: Алтуриак
|
||||||
|
3: Чес
|
||||||
|
4: Тарсак
|
||||||
|
5: Миртул
|
||||||
|
6: Киторн
|
||||||
|
7: Флеймрул
|
||||||
|
8: Элейнт (допускается "Элизис")
|
||||||
|
9: Элесиас
|
||||||
|
10: Марпенот
|
||||||
|
11: Уктар (допускается "Укта")
|
||||||
|
12: Найтол
|
||||||
|
При mode="shift" month должен быть числом (целым, может быть отрицательным).
|
||||||
|
|
||||||
|
Возвращает строку в формате Markdown с результатом изменения и списком праздников на неделю.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
try:
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
except ImportError:
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from time_utils import (
|
||||||
|
get_campaign_time as get_time,
|
||||||
|
set_campaign_time as set_time_absolute,
|
||||||
|
shift_campaign_time,
|
||||||
|
MONTH_NAMES_RU,
|
||||||
|
parse_month,
|
||||||
|
get_week_holidays,
|
||||||
|
get_holiday,
|
||||||
|
is_leap_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeChangeRequest(TypedDict, total=False):
|
||||||
|
"""
|
||||||
|
Запрос на изменение игрового времени.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
mode: Режим операции: "absolute" – установить абсолютное время (используются year, month, day, hour как новые значения);
|
||||||
|
"shift" – сдвинуть текущее время на указанные значения (year, month, day, hour интерпретируются как дельты).
|
||||||
|
По умолчанию: "shift".
|
||||||
|
|
||||||
|
year: При mode="absolute" – год (целое >0). При mode="shift" – сдвиг в годах (может быть отрицательным).
|
||||||
|
|
||||||
|
month: При mode="absolute" – месяц (число 1-12 или русское название). При mode="shift" – сдвиг в месяцах (целое, может быть отрицательным).
|
||||||
|
|
||||||
|
Допустимые русские названия (регистр не важен):
|
||||||
|
1: Хаммер
|
||||||
|
2: Алтуриак
|
||||||
|
3: Чес
|
||||||
|
4: Тарсак
|
||||||
|
5: Миртул
|
||||||
|
6: Киторн
|
||||||
|
7: Флеймрул
|
||||||
|
8: Элейнт (также принимается "Элизис")
|
||||||
|
9: Элесиас
|
||||||
|
10: Марпенот
|
||||||
|
11: Уктар (также принимается "Укта")
|
||||||
|
12: Найтол
|
||||||
|
|
||||||
|
day: При mode="absolute" – день (1–30). При mode="shift" – сдвиг в днях (может быть отрицательным).
|
||||||
|
hour: При mode="absolute" – час (0–23). При mode="shift" – сдвиг в часах (может быть отрицательным).
|
||||||
|
"""
|
||||||
|
mode: str # "absolute" | "shift", default "shift"
|
||||||
|
year: int
|
||||||
|
month: int | str
|
||||||
|
day: int
|
||||||
|
hour: int
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(year: int, month: int, day: int, hour: int) -> str:
|
||||||
|
month_name = MONTH_NAMES_RU.get(month, str(month))
|
||||||
|
return f"{day} {month_name} {year} DR, {hour:02d}:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_holidays_section(year: int, month: int, day: int) -> str:
|
||||||
|
holidays = get_week_holidays(year, month, day)
|
||||||
|
if not holidays:
|
||||||
|
return "📅 **Ближайшие праздники:** нет"
|
||||||
|
lines = ["📅 **Ближайшие праздники:**"]
|
||||||
|
for m, d, desc, when in holidays:
|
||||||
|
month_name = MONTH_NAMES_RU.get(m, str(m))
|
||||||
|
lines.append(f"- **{when}** ({d} {month_name}): {desc}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def set_campaign_time(debug_description: str, requests: list[TimeChangeRequest]) -> str:
|
||||||
|
if not requests:
|
||||||
|
requests = [{}]
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for idx, req in enumerate(requests, start=1):
|
||||||
|
mode = req.get("mode", "shift")
|
||||||
|
old_time = get_time()
|
||||||
|
old_str = _format_time(old_time["year"], old_time["month"], old_time["day"], old_time["hour"])
|
||||||
|
|
||||||
|
if mode == "absolute":
|
||||||
|
new_year = old_time["year"]
|
||||||
|
new_month = old_time["month"]
|
||||||
|
new_day = old_time["day"]
|
||||||
|
new_hour = old_time["hour"]
|
||||||
|
|
||||||
|
if "year" in req:
|
||||||
|
new_year = req["year"]
|
||||||
|
if "month" in req:
|
||||||
|
try:
|
||||||
|
new_month = parse_month(req["month"])
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
sections.append(f"### ❌ Ошибка в запросе #{idx}\n{e}")
|
||||||
|
continue
|
||||||
|
if "day" in req:
|
||||||
|
new_day = req["day"]
|
||||||
|
if "hour" in req:
|
||||||
|
new_hour = req["hour"]
|
||||||
|
|
||||||
|
if new_year <= 0:
|
||||||
|
sections.append(f"### ❌ Ошибка в запросе #{idx}\nГод должен быть > 0, получено {new_year}.")
|
||||||
|
continue
|
||||||
|
if not (1 <= new_month <= 12):
|
||||||
|
sections.append(f"### ❌ Ошибка в запросе #{idx}\nМесяц должен быть 1-12, получено {new_month}.")
|
||||||
|
continue
|
||||||
|
if not (1 <= new_day <= 30):
|
||||||
|
sections.append(f"### ❌ Ошибка в запросе #{idx}\nДень должен быть 1-30, получено {new_day}.")
|
||||||
|
continue
|
||||||
|
if not (0 <= new_hour <= 23):
|
||||||
|
sections.append(f"### ❌ Ошибка в запросе #{idx}\nЧас должен быть 0-23, получено {new_hour}.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
success = set_time_absolute(new_year, new_month, new_day, new_hour)
|
||||||
|
if success:
|
||||||
|
new_time = get_time()
|
||||||
|
new_str = _format_time(new_time["year"], new_time["month"], new_time["day"], new_time["hour"])
|
||||||
|
sections.append(
|
||||||
|
f"### 📅 Установка времени #{idx}\n"
|
||||||
|
f"- Старое: {old_str}\n"
|
||||||
|
f"- **Новое:** {new_str}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sections.append(f"### ❌ Ошибка записи времени #{idx}")
|
||||||
|
|
||||||
|
else: # shift
|
||||||
|
delta_year = req.get("year", 0)
|
||||||
|
delta_month = req.get("month", 0)
|
||||||
|
delta_day = req.get("day", 0)
|
||||||
|
delta_hour = req.get("hour", 0)
|
||||||
|
|
||||||
|
if "month" in req:
|
||||||
|
try:
|
||||||
|
delta_month = int(req["month"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
sections.append(f"### ❌ Ошибка в запросе #{idx}\nПри mode='shift' month должен быть числом, получено {req['month']}.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if delta_year == 0 and delta_month == 0 and delta_day == 0 and delta_hour == 0:
|
||||||
|
current = get_time()
|
||||||
|
current_str = _format_time(current["year"], current["month"], current["day"], current["hour"])
|
||||||
|
sections.append(
|
||||||
|
f"### 🕰️ Текущее время #{idx}\n"
|
||||||
|
f"**Время:** {current_str}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_time = shift_campaign_time(delta_year, delta_month, delta_day, delta_hour)
|
||||||
|
new_str = _format_time(new_time["year"], new_time["month"], new_time["day"], new_time["hour"])
|
||||||
|
parts = []
|
||||||
|
if delta_year:
|
||||||
|
parts.append(f"{delta_year:+d} лет")
|
||||||
|
if delta_month:
|
||||||
|
parts.append(f"{delta_month:+d} месяцев")
|
||||||
|
if delta_day:
|
||||||
|
parts.append(f"{delta_day:+d} дней")
|
||||||
|
if delta_hour:
|
||||||
|
parts.append(f"{delta_hour:+d} часов")
|
||||||
|
shift_desc = ", ".join(parts) if parts else "0"
|
||||||
|
sections.append(
|
||||||
|
f"### ⏳ Сдвиг времени #{idx}\n"
|
||||||
|
f"- Сдвиг: {shift_desc}\n"
|
||||||
|
f"- Старое: {old_str}\n"
|
||||||
|
f"- **Новое:** {new_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
final_time = get_time()
|
||||||
|
holidays_section = _format_holidays_section(
|
||||||
|
final_time["year"],
|
||||||
|
final_time["month"],
|
||||||
|
final_time["day"]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = "\n\n---\n\n".join(sections)
|
||||||
|
result += f"\n\n---\n\n{holidays_section}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = set_campaign_time("тест", [
|
||||||
|
{"mode": "shift", "day": 1, "hour": 5},
|
||||||
|
{"mode": "absolute", "year": 1492, "month": 1, "day": 1, "hour": 0},
|
||||||
|
{"mode": "absolute", "hour": 12},
|
||||||
|
{"mode": "absolute", "month": "Миртул", "day": 15},
|
||||||
|
{"mode": "absolute", "month": "Укта"},
|
||||||
|
{},
|
||||||
|
])
|
||||||
|
print(result)
|
||||||
215
time/time_utils.py
Normal file
215
time/time_utils.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""
|
||||||
|
time_utils.py — утилиты для работы с игровым временем в Obsidian vault.
|
||||||
|
|
||||||
|
Хранит время в файле Campaign_Time.md в корне хранилища, в frontmatter.
|
||||||
|
Поля: year, month, day, hour.
|
||||||
|
Месяц = 30 дней, год = 360 дней (упрощение для Forgotten Realms).
|
||||||
|
|
||||||
|
Экспортирует:
|
||||||
|
get_campaign_time() -> dict
|
||||||
|
set_campaign_time(y,m,d,h) -> bool
|
||||||
|
shift_campaign_time(...) -> dict
|
||||||
|
get_week_holidays() -> list[tuple[ int, int, str, str ]]
|
||||||
|
MONTH_NAMES_RU -> dict[int, str]
|
||||||
|
MONTH_NAME_TO_NUM -> dict[str, int] # только русские названия
|
||||||
|
HOLIDAYS -> dict[tuple[int, int], str]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
|
# --- КОРРЕКЦИЯ ПУТЕЙ ---
|
||||||
|
scripts_path = Path(__file__).resolve().parent.parent
|
||||||
|
if str(scripts_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(scripts_path))
|
||||||
|
|
||||||
|
from vault_utils import vault_query, write_file, create_file
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# НАЗВАНИЯ МЕСЯЦЕВ (только русские)
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
MONTH_NAMES_RU = {
|
||||||
|
1: "Хаммер",
|
||||||
|
2: "Алтуриак",
|
||||||
|
3: "Чес",
|
||||||
|
4: "Тарсак",
|
||||||
|
5: "Миртул",
|
||||||
|
6: "Киторн",
|
||||||
|
7: "Флеймрул",
|
||||||
|
8: "Элейнт",
|
||||||
|
9: "Элесиас",
|
||||||
|
10: "Марпенот",
|
||||||
|
11: "Уктар",
|
||||||
|
12: "Найтол",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обратный словарь: русское название → номер
|
||||||
|
# Добавляем варианты: "Укта" (без "р") и "Элизис" (синоним Элейнта)
|
||||||
|
MONTH_NAME_TO_NUM = {}
|
||||||
|
for num, name in MONTH_NAMES_RU.items():
|
||||||
|
MONTH_NAME_TO_NUM[name.lower()] = num
|
||||||
|
# Дополнительные варианты, принятые в сообществе
|
||||||
|
MONTH_NAME_TO_NUM["укта"] = 11 # вариант без "р"
|
||||||
|
MONTH_NAME_TO_NUM["элизис"] = 8 # альтернативное написание для Элейнта
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# ПРАЗДНИКИ И ОСОБЫЕ ДНИ
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
HOLIDAYS = {
|
||||||
|
# Межкалендарные дни (31-й день месяца)
|
||||||
|
(1, 31): "🌨️ Середина зимы (Midwinter) — праздник середины зимы",
|
||||||
|
(4, 31): "🌿 Зеленотравье (Greengrass) — праздник прихода весны",
|
||||||
|
(7, 31): "☀️ Середина лета (Midsummer) — летнее солнцестояние, праздник огней",
|
||||||
|
(8, 31): "🌾 Высокая жатва (Highharvesttide) — праздник урожая",
|
||||||
|
(11, 31): "🌙 Праздник луны (Feast of the Moon) — день поминовения предков",
|
||||||
|
|
||||||
|
# Астрономические даты
|
||||||
|
(3, 19): "🌸 Весеннее равноденствие",
|
||||||
|
(6, 20): "☀️ Летнее солнцестояние",
|
||||||
|
(9, 21): "🍂 Осеннее равноденствие",
|
||||||
|
(12, 20): "❄️ Зимнее солнцестояние",
|
||||||
|
}
|
||||||
|
|
||||||
|
SHIELDMET_DESC = "🛡️ Щитовой сход (Shieldmeet) — високосный день, праздник обновления клятв"
|
||||||
|
|
||||||
|
|
||||||
|
def is_leap_year(year: int) -> bool:
|
||||||
|
return year % 4 == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_holiday(month: int, day: int, year: int) -> Optional[str]:
|
||||||
|
key = (month, day)
|
||||||
|
if key in HOLIDAYS:
|
||||||
|
return HOLIDAYS[key]
|
||||||
|
if month == 6 and day == 31 and is_leap_year(year):
|
||||||
|
return SHIELDMET_DESC
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_holidays(year: int, month: int, day: int) -> List[Tuple[int, int, str, str]]:
|
||||||
|
result = []
|
||||||
|
for offset in range(7):
|
||||||
|
y, m, d = year, month, day + offset
|
||||||
|
while d > 30:
|
||||||
|
d -= 30
|
||||||
|
m += 1
|
||||||
|
if m > 12:
|
||||||
|
m = 1
|
||||||
|
y += 1
|
||||||
|
holiday_desc = get_holiday(m, d, y)
|
||||||
|
if holiday_desc:
|
||||||
|
if offset == 0:
|
||||||
|
day_str = "сегодня"
|
||||||
|
elif offset == 1:
|
||||||
|
day_str = "завтра"
|
||||||
|
else:
|
||||||
|
day_str = f"через {offset} дней"
|
||||||
|
result.append((m, d, holiday_desc, day_str))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# ОСНОВНЫЕ ФУНКЦИИ РАБОТЫ С ВРЕМЕНЕМ
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
CAMPAIGN_TIME_PATH = "Campaign_Time.md"
|
||||||
|
DEFAULT_TIME = (1492, 1, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_time(year: int, month: int, day: int, hour: int) -> tuple[int, int, int, int]:
|
||||||
|
extra_days, hour = divmod(hour, 24)
|
||||||
|
day += extra_days
|
||||||
|
extra_months, day = divmod(day - 1, 30)
|
||||||
|
day += 1
|
||||||
|
month += extra_months
|
||||||
|
extra_years, month = divmod(month - 1, 12)
|
||||||
|
month += 1
|
||||||
|
year += extra_years
|
||||||
|
if year < 1:
|
||||||
|
year = 1
|
||||||
|
return year, month, day, hour
|
||||||
|
|
||||||
|
|
||||||
|
def get_campaign_time() -> dict:
|
||||||
|
try:
|
||||||
|
data = vault_query("get_frontmatter", {"path": CAMPAIGN_TIME_PATH})
|
||||||
|
except RuntimeError:
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if data and "frontmatter" in data:
|
||||||
|
fm = data["frontmatter"]
|
||||||
|
year = fm.get("year")
|
||||||
|
month = fm.get("month")
|
||||||
|
day = fm.get("day")
|
||||||
|
hour = fm.get("hour")
|
||||||
|
if all(v is not None for v in (year, month, day, hour)):
|
||||||
|
return {"year": year, "month": month, "day": day, "hour": hour}
|
||||||
|
|
||||||
|
y, m, d, h = DEFAULT_TIME
|
||||||
|
set_campaign_time(y, m, d, h)
|
||||||
|
return {"year": y, "month": m, "day": d, "hour": h}
|
||||||
|
|
||||||
|
|
||||||
|
def set_campaign_time(year: int, month: int, day: int, hour: int) -> bool:
|
||||||
|
if not (1 <= month <= 12) or not (1 <= day <= 30) or not (0 <= hour <= 23):
|
||||||
|
return False
|
||||||
|
frontmatter = {"year": year, "month": month, "day": day, "hour": hour}
|
||||||
|
return write_file(CAMPAIGN_TIME_PATH, frontmatter, "")
|
||||||
|
|
||||||
|
|
||||||
|
def shift_campaign_time(
|
||||||
|
delta_years: int = 0,
|
||||||
|
delta_months: int = 0,
|
||||||
|
delta_days: int = 0,
|
||||||
|
delta_hours: int = 0
|
||||||
|
) -> dict:
|
||||||
|
current = get_campaign_time()
|
||||||
|
y = current["year"] + delta_years
|
||||||
|
m = current["month"] + delta_months
|
||||||
|
d = current["day"] + delta_days
|
||||||
|
h = current["hour"] + delta_hours
|
||||||
|
y, m, d, h = _normalize_time(y, m, d, h)
|
||||||
|
set_campaign_time(y, m, d, h)
|
||||||
|
return {"year": y, "month": m, "day": d, "hour": h}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_month(month_input) -> int:
|
||||||
|
"""
|
||||||
|
Преобразует месяц из числа или строки в номер (1-12).
|
||||||
|
|
||||||
|
Поддерживает:
|
||||||
|
- целые числа 1-12
|
||||||
|
- строки-числа "1" … "12"
|
||||||
|
- русские названия (регистр не важен):
|
||||||
|
1: Хаммер
|
||||||
|
2: Алтуриак
|
||||||
|
3: Чес
|
||||||
|
4: Тарсак
|
||||||
|
5: Миртул
|
||||||
|
6: Киторн
|
||||||
|
7: Флеймрул
|
||||||
|
8: Элейнт
|
||||||
|
9: Элесиас
|
||||||
|
10: Марпенот
|
||||||
|
11: Уктар (допускается "Укта")
|
||||||
|
12: Найтол
|
||||||
|
"""
|
||||||
|
if isinstance(month_input, int):
|
||||||
|
if 1 <= month_input <= 12:
|
||||||
|
return month_input
|
||||||
|
raise ValueError(f"Номер месяца должен быть от 1 до 12, получено {month_input}")
|
||||||
|
|
||||||
|
if isinstance(month_input, str):
|
||||||
|
try:
|
||||||
|
num = int(month_input)
|
||||||
|
if 1 <= num <= 12:
|
||||||
|
return num
|
||||||
|
raise ValueError(f"Номер месяца должен быть от 1 до 12, получено {num}")
|
||||||
|
except ValueError:
|
||||||
|
key = month_input.lower().strip()
|
||||||
|
if key in MONTH_NAME_TO_NUM:
|
||||||
|
return MONTH_NAME_TO_NUM[key]
|
||||||
|
raise ValueError(f"Неизвестное название месяца: '{month_input}'")
|
||||||
|
|
||||||
|
raise TypeError(f"month должен быть int или str, получено {type(month_input)}")
|
||||||
135
vault_utils.py
Normal file
135
vault_utils.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""
|
||||||
|
vault_utils.py — универсальные утилиты для работы с Obsidian vault через HTTP-прокси.
|
||||||
|
|
||||||
|
Содержит функции, переиспользуемые любыми модулями (фракции, время, события и т.д.).
|
||||||
|
|
||||||
|
Экспортирует:
|
||||||
|
vault_query(query_type, payload) — базовый вызов к бэкенду
|
||||||
|
write_file(path, frontmatter, body) — запись frontmatter и тела файла
|
||||||
|
create_file(path, content) — создание файла с содержимым
|
||||||
|
normalize_wikilink(s) — приведение wikilink к нормализованному виду
|
||||||
|
resolve_wikilink(name) — поиск пути к файлу по имени/алиасу
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import requests as http_requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Настройки
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BACKEND_URL = "http://localhost:5000/api"
|
||||||
|
VAULT_QUERY_TIMEOUT = 12 # секунд
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Базовый клиент
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def vault_query(query_type: str, payload: dict) -> any:
|
||||||
|
"""
|
||||||
|
Отправляет запрос к Obsidian vault через бэкенд-прокси.
|
||||||
|
|
||||||
|
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
|
||||||
|
Бросает RuntimeError при недоступности бэкенда или timeout.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
query_type: тип операции
|
||||||
|
payload: параметры операции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{BACKEND_URL}/vault/query",
|
||||||
|
json={"type": query_type, "payload": payload},
|
||||||
|
timeout=VAULT_QUERY_TIMEOUT,
|
||||||
|
proxies={"http": None, "https": None}
|
||||||
|
)
|
||||||
|
except http_requests.exceptions.ConnectionError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Бэкенд недоступен ({BACKEND_URL}). "
|
||||||
|
"Убедитесь что Flask-сервер запущен."
|
||||||
|
)
|
||||||
|
except http_requests.exceptions.Timeout:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"vault_query timeout после {VAULT_QUERY_TIMEOUT}с. "
|
||||||
|
"Возможно Obsidian не подключён к WebSocket."
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 504:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Obsidian не ответил на запрос vault за 10 секунд. "
|
||||||
|
"Проверьте что плагин LLM Agent запущен и подключён."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
try:
|
||||||
|
err = resp.json().get("error", resp.text)
|
||||||
|
except Exception:
|
||||||
|
err = resp.text
|
||||||
|
raise RuntimeError(f"vault_query ошибка {resp.status_code}: {err}")
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Запись данных (универсальные)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def write_file(file_path: str, frontmatter: dict, body: str) -> bool:
|
||||||
|
"""
|
||||||
|
Сохраняет frontmatter и тело файла в Obsidian vault.
|
||||||
|
|
||||||
|
file_path — путь внутри vault, например 'Фракции/Cult_of_the_Dragon.md'
|
||||||
|
frontmatter — словарь с данными (поля YAML)
|
||||||
|
body — содержимое файла после frontmatter
|
||||||
|
"""
|
||||||
|
result = vault_query("write_frontmatter", {
|
||||||
|
"path": file_path,
|
||||||
|
"frontmatter": frontmatter,
|
||||||
|
"body": body
|
||||||
|
})
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result.get("success", False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_file(file_path: str, content: str) -> bool:
|
||||||
|
"""
|
||||||
|
Создаёт файл в Obsidian vault (перезаписывает, если существует).
|
||||||
|
Промежуточные папки создаются автоматически.
|
||||||
|
"""
|
||||||
|
result = vault_query("create_file", {
|
||||||
|
"path": file_path,
|
||||||
|
"content": content
|
||||||
|
})
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result.get("success", False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Нормализация и резолвинг wikilink
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def normalize_wikilink(s: str) -> str:
|
||||||
|
"""
|
||||||
|
Приводит wikilink к нижнему регистру без скобок и алиасов.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
'[[Waterdeep]]' → 'waterdeep'
|
||||||
|
'[[Waterdeep|City]]' → 'waterdeep'
|
||||||
|
'Waterdeep' → 'waterdeep'
|
||||||
|
'' → ''
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'^\[\[|\]\]$', '', s).split('|')[0].strip().lower()
|
||||||
|
|
||||||
|
def resolve_wikilink(name: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Резолвит wikilink в путь к файлу через Obsidian metadataCache.
|
||||||
|
Знает об aliases.
|
||||||
|
|
||||||
|
Принимает '[[Waterdeep]]', '[[Waterdeep|City]]' или просто 'Waterdeep'.
|
||||||
|
Возвращает путь вида 'TTRPG/Locations/Waterdeep.md' или None.
|
||||||
|
"""
|
||||||
|
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
|
||||||
|
result = vault_query("resolve_wikilink", {"name": original_clean})
|
||||||
|
return result if isinstance(result, str) else None
|
||||||
Loading…
Reference in New Issue
Block a user