test add daily quests

This commit is contained in:
2025-12-13 16:51:28 +05:00
parent f8550c9dc8
commit 4ef3064011
5 changed files with 371 additions and 1 deletions

View File

@ -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}

View File

@ -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)

283
app/services/dailyquests.py Normal file
View File

@ -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

View File

@ -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")

View File

@ -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")