441 lines
18 KiB
Python
441 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
from typing import Any, Dict, List, Optional
|
|
import random
|
|
|
|
from fastapi import HTTPException
|
|
from app.db.database import db, users_collection
|
|
|
|
from app.services.coins import CoinsService
|
|
|
|
TZ = ZoneInfo("Asia/Yekaterinburg") # как в dailyreward :contentReference[oaicite:1]{index=1}
|
|
|
|
coins_sessions_collection = db.coins_sessions
|
|
daily_quests_pool_collection = db.daily_quests_pool
|
|
user_daily_quests_collection = db.user_daily_quests
|
|
|
|
|
|
def _day_bounds_utc(now_utc: datetime):
|
|
# копия подхода из dailyreward :contentReference[oaicite:2]{index=2}
|
|
now_local = now_utc.astimezone(TZ)
|
|
start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
start_utc = start_local.astimezone(timezone.utc)
|
|
next_utc = (start_local + timedelta(days=1)).astimezone(timezone.utc)
|
|
return now_local.date(), start_utc, next_utc, start_local
|
|
|
|
|
|
class DailyQuestsService:
|
|
"""
|
|
Daily Quests:
|
|
- пул (daily_quests_pool) админит бекенд
|
|
- игроку генерятся N рандомных заданий на локальный день (TZ)
|
|
- прогресс обновляется событиями (mob_kill и т.п.)
|
|
- награда выдаётся при claim (атомарно)
|
|
"""
|
|
|
|
DEFAULT_DAILY_COUNT = 3
|
|
|
|
def _choose_by_difficulty(self, pool: List[dict]) -> List[dict]:
|
|
# группируем по сложности
|
|
buckets = {"easy": [], "medium": [], "hard": []}
|
|
for it in pool:
|
|
diff = str(it.get("difficulty", "")).lower()
|
|
if diff in buckets:
|
|
buckets[diff].append(it)
|
|
|
|
chosen: List[dict] = []
|
|
|
|
# по 1 из каждой сложности, weighted, без повторов
|
|
for diff in ("easy", "medium", "hard"):
|
|
items = buckets[diff]
|
|
if not items:
|
|
continue
|
|
pick = random.choices(
|
|
items,
|
|
weights=[max(1, int(x.get("weight", 1) or 1)) for x in items],
|
|
k=1
|
|
)[0]
|
|
chosen.append(pick)
|
|
|
|
# если чего-то не хватило (например нет hard в пуле) —
|
|
# добиваем случайными из оставшихся enabled, чтобы всё равно было 3
|
|
need_total = 3
|
|
if len(chosen) < need_total:
|
|
already = {c.get("key") for c in chosen}
|
|
rest = [x for x in pool if x.get("key") not in already]
|
|
if rest:
|
|
extra = self._weighted_sample_without_replacement(
|
|
rest, k=min(need_total - len(chosen), len(rest))
|
|
)
|
|
chosen.extend(extra)
|
|
|
|
return chosen
|
|
|
|
async def get_status(self, username: str) -> dict:
|
|
now_utc = datetime.now(timezone.utc)
|
|
today_local, start_today_utc, start_tomorrow_utc, start_today_local = _day_bounds_utc(now_utc)
|
|
day_key = today_local.isoformat()
|
|
|
|
user = await users_collection.find_one({"username": username})
|
|
if not user:
|
|
return {"ok": False, "reason": "user_not_found"}
|
|
|
|
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
|
|
|
# (опционально как dailyreward) — требовать “онлайн сегодня”
|
|
was_online_today = await coins_sessions_collection.find_one({
|
|
"player_name": username,
|
|
"update_type": "coins_update",
|
|
"timestamp": {
|
|
"$gte": start_today_utc.replace(tzinfo=None),
|
|
"$lt": start_tomorrow_utc.replace(tzinfo=None),
|
|
},
|
|
})
|
|
|
|
seconds_to_next = int((start_tomorrow_utc - now_utc).total_seconds())
|
|
if seconds_to_next < 0:
|
|
seconds_to_next = 0
|
|
|
|
return {
|
|
"ok": True,
|
|
"day": day_key,
|
|
"was_online_today": bool(was_online_today),
|
|
"seconds_to_next": seconds_to_next,
|
|
"next_reset_at_utc": start_tomorrow_utc.isoformat().replace("+00:00", "Z"),
|
|
"next_reset_at_local": (start_today_local + timedelta(days=1)).isoformat(),
|
|
"quests": doc.get("quests", []),
|
|
}
|
|
|
|
async def claim(self, username: str, quest_key: str) -> dict:
|
|
now_utc = datetime.now(timezone.utc)
|
|
today_local, start_today_utc, start_tomorrow_utc, _ = _day_bounds_utc(now_utc)
|
|
day_key = today_local.isoformat()
|
|
|
|
user = await users_collection.find_one({"username": username})
|
|
if not user:
|
|
return {"claimed": False, "reason": "user_not_found"}
|
|
|
|
# (опционально) требование “онлайн сегодня” как dailyreward :contentReference[oaicite:3]{index=3}
|
|
was_online_today = await coins_sessions_collection.find_one({
|
|
"player_name": username,
|
|
"update_type": "coins_update",
|
|
"timestamp": {
|
|
"$gte": start_today_utc.replace(tzinfo=None),
|
|
"$lt": start_tomorrow_utc.replace(tzinfo=None),
|
|
},
|
|
})
|
|
if not was_online_today:
|
|
return {
|
|
"claimed": False,
|
|
"reason": "not_online_today",
|
|
"message": "Вы должны зайти на сервер сегодня, чтобы получить награду за квест",
|
|
}
|
|
|
|
# найдём квест, чтобы узнать reward (но выдачу делаем атомарно)
|
|
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
|
quest = next((q for q in doc.get("quests", []) if q.get("key") == quest_key), None)
|
|
if not quest:
|
|
return {"claimed": False, "reason": "quest_not_found"}
|
|
|
|
if quest.get("status") == "claimed":
|
|
return {"claimed": False, "reason": "already_claimed"}
|
|
|
|
if quest.get("status") != "completed":
|
|
return {"claimed": False, "reason": "not_completed"}
|
|
|
|
reward = int(quest.get("reward", 0) or 0)
|
|
if reward <= 0:
|
|
return {"claimed": False, "reason": "invalid_reward"}
|
|
|
|
# 1) атомарно помечаем quest claimed (только если он completed и ещё не claimed)
|
|
res = await user_daily_quests_collection.update_one(
|
|
{
|
|
"username": username,
|
|
"day": day_key,
|
|
"quests": {"$elemMatch": {"key": quest_key, "status": "completed"}},
|
|
},
|
|
{
|
|
"$set": {"quests.$.status": "claimed", "quests.$.claimed_at": now_utc.replace(tzinfo=None)}
|
|
},
|
|
)
|
|
|
|
if res.modified_count == 0:
|
|
# кто-то уже заклеймил или статус не completed
|
|
doc2 = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
|
q2 = next((q for q in (doc2 or {}).get("quests", []) if q.get("key") == quest_key), None)
|
|
if q2 and q2.get("status") == "claimed":
|
|
return {"claimed": False, "reason": "already_claimed"}
|
|
return {"claimed": False, "reason": "not_completed"}
|
|
|
|
# 2) начисляем coins
|
|
coins_service = CoinsService()
|
|
await coins_service.increase_balance(username, reward)
|
|
|
|
# 3) лог в coins_sessions (как daily_login) :contentReference[oaicite:4]{index=4}
|
|
await coins_sessions_collection.insert_one({
|
|
"player_name": username,
|
|
"update_type": "daily_quest",
|
|
"timestamp": now_utc.replace(tzinfo=None),
|
|
"quest_key": quest_key,
|
|
"coins_added": reward,
|
|
"day": day_key,
|
|
})
|
|
|
|
return {"claimed": True, "quest_key": quest_key, "coins_added": reward, "day": day_key}
|
|
|
|
async def _auto_claim_completed(self, username: str, day_key: str) -> int:
|
|
"""
|
|
Автоматически забирает награду за все completed квесты, которые ещё не claimed.
|
|
Возвращает сумму начисленных коинов.
|
|
"""
|
|
now_utc = datetime.now(timezone.utc)
|
|
|
|
doc = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
|
if not doc:
|
|
return 0
|
|
|
|
total_added = 0
|
|
|
|
# IMPORTANT: идём по каждому completed и пытаемся атомарно "перевести" в claimed
|
|
for q in doc.get("quests", []):
|
|
if q.get("status") != "completed":
|
|
continue
|
|
|
|
quest_key = q.get("key")
|
|
reward = int(q.get("reward", 0) or 0)
|
|
if not quest_key or reward <= 0:
|
|
continue
|
|
|
|
# атомарно: только если всё ещё completed
|
|
res = await user_daily_quests_collection.update_one(
|
|
{
|
|
"username": username,
|
|
"day": day_key,
|
|
"quests": {"$elemMatch": {"key": quest_key, "status": "completed"}},
|
|
},
|
|
{"$set": {"quests.$.status": "claimed", "quests.$.claimed_at": now_utc.replace(tzinfo=None)}},
|
|
)
|
|
|
|
if res.modified_count == 0:
|
|
continue # уже claimed/не completed
|
|
|
|
# начисляем coins
|
|
coins_service = CoinsService()
|
|
await coins_service.increase_balance(username, reward)
|
|
total_added += reward
|
|
|
|
# лог (как в claim) :contentReference[oaicite:2]{index=2}
|
|
await coins_sessions_collection.insert_one({
|
|
"player_name": username,
|
|
"update_type": "daily_quest",
|
|
"timestamp": now_utc.replace(tzinfo=None),
|
|
"quest_key": quest_key,
|
|
"coins_added": reward,
|
|
"day": day_key,
|
|
"auto": True,
|
|
})
|
|
|
|
return total_added
|
|
|
|
async def on_mob_kill(self, username: str, mob: str, count: int = 1) -> dict:
|
|
"""
|
|
Вызывается EventService при событии mob_kill.
|
|
mob: например "SPIDER"
|
|
"""
|
|
if count <= 0:
|
|
return {"ok": True, "updated": 0}
|
|
|
|
now_utc = datetime.now(timezone.utc)
|
|
today_local, _, _, _ = _day_bounds_utc(now_utc)
|
|
day_key = today_local.isoformat()
|
|
|
|
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
|
|
|
updated = 0
|
|
quests = doc.get("quests", [])
|
|
|
|
# пройдёмся по квестам и обновим подходящие
|
|
for q in quests:
|
|
if q.get("status") in ("claimed",):
|
|
continue
|
|
if q.get("event") != "mob_kill":
|
|
continue
|
|
if str(q.get("target")).upper() != str(mob).upper():
|
|
continue
|
|
|
|
need = int(q.get("required", 0) or 0)
|
|
if need <= 0:
|
|
continue
|
|
|
|
# инкремент прогресса атомарно по элементу массива
|
|
res = await user_daily_quests_collection.update_one(
|
|
{"username": username, "day": day_key, "quests.key": q.get("key")},
|
|
{"$inc": {"quests.$.progress": int(count)}},
|
|
)
|
|
if res.modified_count:
|
|
updated += 1
|
|
|
|
# после инкрементов — доведём status до completed там, где progress >= required
|
|
# (делаем 1 запросом: вытянем документ и проставим статусы на сервере, чтобы было проще и надёжнее)
|
|
doc2 = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
|
if not doc2:
|
|
return {"ok": True, "updated": updated}
|
|
|
|
changed = False
|
|
for q in doc2.get("quests", []):
|
|
if q.get("status") in ("completed", "claimed"):
|
|
continue
|
|
need = int(q.get("required", 0) or 0)
|
|
prog = int(q.get("progress", 0) or 0)
|
|
if need > 0 and prog >= need:
|
|
q["status"] = "completed"
|
|
q["completed_at"] = now_utc.replace(tzinfo=None)
|
|
changed = True
|
|
|
|
if changed:
|
|
await user_daily_quests_collection.update_one(
|
|
{"_id": doc2["_id"]},
|
|
{"$set": {"quests": doc2["quests"]}},
|
|
)
|
|
|
|
coins_added = await self._auto_claim_completed(username, day_key)
|
|
return {"ok": True, "updated": updated, "coins_added": coins_added}
|
|
|
|
async def _get_or_generate_for_day(self, username: str, day_key: str) -> dict:
|
|
existing = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
|
if existing:
|
|
return existing
|
|
|
|
# генерируем
|
|
pool = await daily_quests_pool_collection.find({"enabled": True}).to_list(1000)
|
|
|
|
if not pool:
|
|
# нет активных квестов — всё равно создадим пустой документ
|
|
doc = {
|
|
"username": username,
|
|
"day": day_key,
|
|
"quests": [],
|
|
"created_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
|
}
|
|
await user_daily_quests_collection.insert_one(doc)
|
|
return doc
|
|
|
|
chosen = self._choose_by_difficulty(pool)
|
|
|
|
quests = []
|
|
for tpl in chosen:
|
|
min_req = int(tpl.get("min_required", 1) or 1)
|
|
max_req = int(tpl.get("max_required", min_req) or min_req)
|
|
if max_req < min_req:
|
|
max_req = min_req
|
|
|
|
required = random.randint(min_req, max_req)
|
|
reward_per_unit = int(tpl.get("reward_per_unit", 1) or 1)
|
|
reward = max(1, required * reward_per_unit)
|
|
|
|
quests.append({
|
|
"key": tpl["key"],
|
|
"title": self._render_title(tpl, required),
|
|
"event": tpl.get("event"),
|
|
"target": tpl.get("target"),
|
|
"required": required,
|
|
"progress": 0,
|
|
"reward": reward,
|
|
"status": "active",
|
|
})
|
|
|
|
doc = {
|
|
"username": username,
|
|
"day": day_key,
|
|
"quests": quests,
|
|
"created_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
|
}
|
|
await user_daily_quests_collection.insert_one(doc)
|
|
return doc
|
|
|
|
async def on_active_time_tick(self, username: str, seconds: int) -> dict:
|
|
"""
|
|
Начисляет активное время игроку (секунды) и обновляет квесты event == active_time.
|
|
"""
|
|
if seconds <= 0:
|
|
return {"ok": True, "updated": 0, "coins_added": 0}
|
|
|
|
minutes = int(seconds) // 60
|
|
if minutes <= 0:
|
|
return {"ok": True, "updated": 0, "coins_added": 0}
|
|
|
|
now_utc = datetime.now(timezone.utc)
|
|
today_local, _, _, _ = _day_bounds_utc(now_utc)
|
|
day_key = today_local.isoformat()
|
|
|
|
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
|
quests = doc.get("quests", [])
|
|
updated = 0
|
|
|
|
# инкрементим прогресс (секунды)
|
|
for q in quests:
|
|
if q.get("status") in ("claimed",):
|
|
continue
|
|
if q.get("event") != "active_time":
|
|
continue
|
|
|
|
need = int(q.get("required", 0) or 0)
|
|
if need <= 0:
|
|
continue
|
|
|
|
res = await user_daily_quests_collection.update_one(
|
|
{"username": username, "day": day_key, "quests.key": q.get("key")},
|
|
{"$inc": {"quests.$.progress": minutes}},
|
|
)
|
|
if res.modified_count:
|
|
updated += 1
|
|
|
|
# доводим статусы до completed
|
|
doc2 = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
|
if not doc2:
|
|
return {"ok": True, "updated": updated, "coins_added": 0}
|
|
|
|
changed = False
|
|
for q in doc2.get("quests", []):
|
|
if q.get("status") in ("completed", "claimed"):
|
|
continue
|
|
if q.get("event") != "active_time":
|
|
continue
|
|
|
|
need = int(q.get("required", 0) or 0)
|
|
prog = int(q.get("progress", 0) or 0)
|
|
if need > 0 and prog >= need:
|
|
q["status"] = "completed"
|
|
q["completed_at"] = now_utc.replace(tzinfo=None)
|
|
changed = True
|
|
|
|
if changed:
|
|
await user_daily_quests_collection.update_one(
|
|
{"_id": doc2["_id"]},
|
|
{"$set": {"quests": doc2["quests"]}},
|
|
)
|
|
|
|
coins_added = await self._auto_claim_completed(username, day_key)
|
|
return {"ok": True, "updated": updated, "coins_added": coins_added}
|
|
|
|
def _render_title(self, tpl: dict, required: int) -> str:
|
|
# если в пуле есть "title" как шаблон — используем
|
|
title = tpl.get("title") or tpl.get("key")
|
|
# простой хак: если в title нет числа, добавим
|
|
if any(ch.isdigit() for ch in title):
|
|
return title
|
|
return f"{title} x{required}"
|
|
|
|
def _weighted_sample_without_replacement(self, items: List[dict], k: int) -> List[dict]:
|
|
# простая реализация без внешних либ
|
|
pool = items[:]
|
|
chosen = []
|
|
for _ in range(k):
|
|
weights = [max(1, int(it.get("weight", 1) or 1)) for it in pool]
|
|
pick = random.choices(pool, weights=weights, k=1)[0]
|
|
chosen.append(pick)
|
|
pool.remove(pick)
|
|
return chosen
|