diff --git a/app/api/admin_daily_quests.py b/app/api/admin_daily_quests.py new file mode 100644 index 0000000..f0d09ab --- /dev/null +++ b/app/api/admin_daily_quests.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, HTTPException, Body, Query +from datetime import datetime, timezone +from app.db.database import db + +router = APIRouter(prefix="/api/admin/daily-quests", tags=["Admin Daily Quests"]) + +pool = db.daily_quests_pool + +@router.get("/pool") +async def list_pool(enabled: bool | None = Query(None)): + q = {} + if enabled is not None: + q["enabled"] = enabled + items = await pool.find(q, {"_id": 0}).sort("key", 1).to_list(1000) + return {"ok": True, "items": items, "count": len(items)} + +@router.post("/pool/upsert") +async def upsert_pool_item(payload: dict = Body(...)): + # минимальная валидация + key = payload.get("key") + if not key or not isinstance(key, str): + raise HTTPException(status_code=400, detail="Missing key") + + # дефолты + payload.setdefault("enabled", True) + payload.setdefault("weight", 1) + payload.setdefault("min_required", 1) + payload.setdefault("max_required", payload["min_required"]) + payload.setdefault("reward_per_unit", 1) + + # тех.поля + payload["updated_at"] = datetime.now(timezone.utc).replace(tzinfo=None) + + await pool.update_one({"key": key}, {"$set": payload}, upsert=True) + item = await pool.find_one({"key": key}, {"_id": 0}) + return {"ok": True, "item": item} + +@router.post("/pool/disable") +async def disable_pool_item(key: str = Query(...)): + res = await pool.update_one({"key": key}, {"$set": {"enabled": False}}) + if res.matched_count == 0: + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + +@router.post("/pool/enable") +async def enable_pool_item(key: str = Query(...)): + res = await pool.update_one({"key": key}, {"$set": {"enabled": True}}) + if res.matched_count == 0: + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + +@router.delete("/pool") +async def delete_pool_item(key: str = Query(...)): + res = await pool.delete_one({"key": key}) + if res.deleted_count == 0: + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} diff --git a/app/api/users.py b/app/api/users.py index c6ae08b..a781f2d 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -12,6 +12,7 @@ from app.models.server.event import PlayerEvent, OnlinePlayersUpdate from app.models.server.playtime import PlayerSession, PlayerPlaytime from app.services.coins import CoinsService from app.services.dailyreward import DailyRewardService +from app.services.dailyquests import DailyQuestsService coins_service = CoinsService() @@ -167,3 +168,19 @@ async def daily_days( ): me = await AuthService().get_current_user(accessToken, clientToken) return await DailyRewardService().get_claim_days(me["username"], limit=limit) + +### daily quests + +@router.get("/users/daily-quests/status") +async def daily_quests_status(accessToken: str = Query(...), clientToken: str = Query(...)): + me = await AuthService().get_current_user(accessToken, clientToken) + return await DailyQuestsService().get_status(me["username"]) + +@router.post("/users/daily-quests/claim") +async def daily_quests_claim( + quest_key: str = Query(...), + accessToken: str = Query(...), + clientToken: str = Query(...), +): + me = await AuthService().get_current_user(accessToken, clientToken) + return await DailyQuestsService().claim(me["username"], quest_key) \ No newline at end of file diff --git a/app/services/dailyquests.py b/app/services/dailyquests.py new file mode 100644 index 0000000..3fe305d --- /dev/null +++ b/app/services/dailyquests.py @@ -0,0 +1,283 @@ +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 + + 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 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"]}}, + ) + + return {"ok": True, "updated": updated} + + 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 + + count = self.DEFAULT_DAILY_COUNT + # weighted random without replacement + chosen = self._weighted_sample_without_replacement(pool, k=min(count, len(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 diff --git a/app/services/server/event.py b/app/services/server/event.py index f746c3d..7ef3b14 100644 --- a/app/services/server/event.py +++ b/app/services/server/event.py @@ -4,6 +4,7 @@ import json from app.services.coins import CoinsService from app.models.server.event import PlayerEvent, OnlinePlayersUpdate import uuid +from app.services.dailyquests import DailyQuestsService class EventService: def __init__(self): @@ -69,6 +70,17 @@ class EventService: await self._process_player_session(server_ip, player_id, player_name, duration) return {"status": "success"} + elif event_type == "mob_kill": + player_name = event_data.get("player_name") + mob = event_data.get("mob") + count = int(event_data.get("count", 1) or 1) + + if not player_name or not mob: + raise HTTPException(status_code=400, detail="Missing mob_kill data") + + await DailyQuestsService().on_mob_kill(player_name, mob, count) + return {"status": "success"} + # Если тип события не распознан print(f"[{datetime.now()}] Неизвестное событие: {event_data}") raise HTTPException(status_code=400, detail="Invalid event type") diff --git a/main.py b/main.py index 13e4ea1..d690692 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ import os from fastapi import FastAPI from fastapi.staticfiles import StaticFiles import httpx -from app.api import news, users, skins, capes, meta, server, store, pranks, marketplace, bonuses, case +from app.api import admin_daily_quests, news, users, skins, capes, meta, server, store, pranks, marketplace, bonuses, case from fastapi.middleware.cors import CORSMiddleware from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR @@ -62,6 +62,7 @@ app.include_router(bonuses.router) app.include_router(news.router) app.include_router(case.router) app.include_router(telegram.router) +app.include_router(admin_daily_quests.router) # Монтируем статику app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")