Files
popa_minecraft_launcher_api/app/services/dailyquests.py
2025-12-13 17:49:41 +05:00

372 lines
15 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
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
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