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 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 await users_collection.update_one({"username": username}, {"$inc": {"coins": 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 await users_collection.update_one({"username": username}, {"$inc": {"coins": 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} 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": int(seconds)}}, ) 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