test add daily quests
This commit is contained in:
57
app/api/admin_daily_quests.py
Normal file
57
app/api/admin_daily_quests.py
Normal 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}
|
||||
@ -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
283
app/services/dailyquests.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
3
main.py
3
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")
|
||||
|
||||
Reference in New Issue
Block a user