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.models.server.playtime import PlayerSession, PlayerPlaytime
|
||||||
from app.services.coins import CoinsService
|
from app.services.coins import CoinsService
|
||||||
from app.services.dailyreward import DailyRewardService
|
from app.services.dailyreward import DailyRewardService
|
||||||
|
from app.services.dailyquests import DailyQuestsService
|
||||||
|
|
||||||
coins_service = CoinsService()
|
coins_service = CoinsService()
|
||||||
|
|
||||||
@ -167,3 +168,19 @@ async def daily_days(
|
|||||||
):
|
):
|
||||||
me = await AuthService().get_current_user(accessToken, clientToken)
|
me = await AuthService().get_current_user(accessToken, clientToken)
|
||||||
return await DailyRewardService().get_claim_days(me["username"], limit=limit)
|
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.services.coins import CoinsService
|
||||||
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
|
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
|
||||||
import uuid
|
import uuid
|
||||||
|
from app.services.dailyquests import DailyQuestsService
|
||||||
|
|
||||||
class EventService:
|
class EventService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -69,6 +70,17 @@ class EventService:
|
|||||||
await self._process_player_session(server_ip, player_id, player_name, duration)
|
await self._process_player_session(server_ip, player_id, player_name, duration)
|
||||||
return {"status": "success"}
|
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}")
|
print(f"[{datetime.now()}] Неизвестное событие: {event_data}")
|
||||||
raise HTTPException(status_code=400, detail="Invalid event type")
|
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 import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import httpx
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR
|
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(news.router)
|
||||||
app.include_router(case.router)
|
app.include_router(case.router)
|
||||||
app.include_router(telegram.router)
|
app.include_router(telegram.router)
|
||||||
|
app.include_router(admin_daily_quests.router)
|
||||||
|
|
||||||
# Монтируем статику
|
# Монтируем статику
|
||||||
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
|
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
|
||||||
|
|||||||
Reference in New Issue
Block a user