FEAT: Add quest handling

This commit is contained in:
Brandon Nguyen
2026-09-06 17:06:14 -07:00
parent bb2348acf9
commit 694fd54a4d
14 changed files with 397 additions and 25 deletions

View File

@@ -0,0 +1,7 @@
[
{"key": "first_win", "title": "Victory Lap", "description": "Win 1 multiplayer match.", "stat": "wins", "target": 1, "coins": 50, "xp": 1},
{"key": "damage", "title": "On the Offensive", "description": "Deal 500 damage in multiplayer matches.", "stat": "damagedealt", "target": 500, "coins": 50, "xp": 1},
{"key": "energy", "title": "Power Up", "description": "Attach 5 Energy cards from your hand during multiplayer matches.", "stat": "energyplayed", "target": 5, "coins": 40, "xp": 1},
{"key": "trainers", "title": "Trainer's Toolkit", "description": "Play 10 Trainer cards in multiplayer matches.", "stat": "trainersplayed", "target": 10, "coins": 40, "xp": 1},
{"key": "prizes", "title": "Eyes on the Prize", "description": "Take 6 Prize cards in multiplayer matches.", "stat": "prizecardstaken", "target": 6, "coins": 50, "xp": 1}
]

View File

@@ -2,6 +2,7 @@ from spirit.database.base import Base
from spirit.database.models.account import Account
from spirit.database.models.inventory import Wallet, Deck, Collection, ArchetypeFlag
from spirit.database.models.social import Friendship
from spirit.database.models.quests import QuestAccount, DailyQuest, QuestMatchCredit
from spirit.database.models.economy import (
RedemptionCode, CodeRedemptionEntry, ShopItem, TradeOffer, DynamicPage, VersusProgress, DailyLoginProgress
)
@@ -13,5 +14,6 @@ __all__ = [
"Base", "Account", "Wallet", "Deck", "Collection", "ArchetypeFlag", "Friendship",
"RedemptionCode", "CodeRedemptionEntry", "ShopItem", "TradeOffer", "DynamicPage",
"VersusProgress", "DailyLoginProgress",
"QuestAccount", "DailyQuest", "QuestMatchCredit",
"AsyncTournament", "TournamentEntry", "TournamentLeaderboardClaim"
]

View File

@@ -0,0 +1,35 @@
import datetime
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from spirit.database.base import Base
from spirit.database.models.inventory import JSONEncodedDict
class QuestAccount(Base):
__tablename__ = "quest_accounts"
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.account_id"), primary_key=True)
enabled: Mapped[bool] = mapped_column(default=True)
selected_date: Mapped[datetime.date | None] = mapped_column(nullable=True)
affinity_xp: Mapped[dict] = mapped_column(JSONEncodedDict, default=dict)
class DailyQuest(Base):
__tablename__ = "daily_quests"
quest_id: Mapped[str] = mapped_column(primary_key=True)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.account_id"), index=True)
offered_date: Mapped[datetime.date] = mapped_column()
status: Mapped[str] = mapped_column(default="available")
definition: Mapped[dict] = mapped_column(JSONEncodedDict)
activations: Mapped[int] = mapped_column(default=0)
accepted_at: Mapped[float | None] = mapped_column(nullable=True)
class QuestMatchCredit(Base):
__tablename__ = "quest_match_credits"
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.account_id"), primary_key=True)
game_id: Mapped[str] = mapped_column(primary_key=True)

160
spirit/database/quests.py Normal file
View File

@@ -0,0 +1,160 @@
"""Transactional daily challenges and exactly-once match credit."""
import datetime as dt
import hashlib
import uuid
from sqlalchemy import text
from spirit.database import db_session
from spirit.database.models import Account, Wallet, QuestAccount, DailyQuest, QuestMatchCredit
from spirit.game.quests import AFFINITIES, MAX_ACTIVE, XP_LEVELS, coin_reward, configuration, load_catalog, quest_payload
def utc_now():
return dt.datetime.now(dt.timezone.utc)
def _state(session, account_id):
if session.query(Account).filter_by(account_id=account_id).first() is None:
raise ValueError("Unknown account")
state = session.get(QuestAccount, account_id)
if state is None:
state = QuestAccount(account_id=account_id, enabled=True, affinity_xp={})
session.add(state)
session.flush()
return state
def account_quest_attributes(account_id):
with db_session() as session:
state = session.get(QuestAccount, account_id)
return {
"enabled": state.enabled if state else True,
"xp": {key: (state.affinity_xp or {}).get(key, 0) if state else 0 for key in AFFINITIES},
}
def _snapshot(session, state, now):
today = now.date()
rows = session.query(DailyQuest).filter_by(account_id=state.account_id).all()
active = [row for row in rows if row.status == "active"]
for row in rows:
if row.status == "available" and row.offered_date != today:
row.status = "expired"
available = []
if state.enabled and state.selected_date != today and len(active) < MAX_ACTIVE:
available = [row for row in rows if row.status == "available"]
if not available:
active_keys = {row.definition["key"] for row in active}
candidates = [row for row in load_catalog() if row["key"] not in active_keys]
candidates.sort(key=lambda row: hashlib.sha256(
f"{state.account_id}:{today}:{row['key']}".encode()).digest())
for template in candidates[:2]:
quest_id = str(uuid.uuid5(uuid.NAMESPACE_URL,
f"spirit:daily:{state.account_id}:{today}:{template['key']}"))
row = DailyQuest(quest_id=quest_id, account_id=state.account_id,
offered_date=today, status="available",
definition=dict(template), activations=0)
session.add(row)
available.append(row)
tomorrow = dt.datetime.combine(today + dt.timedelta(days=1), dt.time.min, dt.timezone.utc)
next_time = now if available else tomorrow
return {
"quests": {
"currentQuests": [quest_payload(row) for row in active] if state.enabled else [],
"availableQuests": [quest_payload(row) for row in available], "specialQuests": [],
},
"configuration": configuration(int(next_time.timestamp() * 1000)),
}
def get_quests(account_id, *, now=None):
now = now or utc_now()
with db_session() as session:
session.execute(text("BEGIN IMMEDIATE"))
return _snapshot(session, _state(session, account_id), now)
def set_enabled(account_id, enabled, *, now=None):
now = now or utc_now()
with db_session() as session:
session.execute(text("BEGIN IMMEDIATE"))
state = _state(session, account_id)
state.enabled = enabled
return _snapshot(session, state, now)
def select_quest(account_id, quest_ids, *, now=None):
now = now or utc_now()
with db_session() as session:
session.execute(text("BEGIN IMMEDIATE"))
state = _state(session, account_id)
snapshot = _snapshot(session, state, now)
valid = {q["questDefinition"]["questID"] for q in snapshot["quests"]["availableQuests"]}
if len(quest_ids) != 1 or quest_ids[0] not in valid:
return snapshot
session.flush()
row = session.get(DailyQuest, quest_ids[0])
row.status = "active"
row.accepted_at = now.timestamp()
state.selected_date = now.date()
session.flush()
return _snapshot(session, state, now)
def abandon_quests(account_id, quest_ids, *, now=None):
now = now or utc_now()
with db_session() as session:
session.execute(text("BEGIN IMMEDIATE"))
state = _state(session, account_id)
for row in session.query(DailyQuest).filter_by(account_id=account_id, status="active"):
if row.quest_id in quest_ids:
row.status = "abandoned"
session.flush()
return _snapshot(session, state, now)
def credit_match(account_id, game_id, stats, won, started_at):
"""Applies server-owned counters and grants completed rewards atomically."""
result = {"completedQuestsAndXPTotal": [], "progressedQuests": [], "rewards": []}
with db_session() as session:
session.execute(text("BEGIN IMMEDIATE"))
state = _state(session, account_id)
if session.get(QuestMatchCredit, (account_id, game_id)) is not None:
return result
session.add(QuestMatchCredit(account_id=account_id, game_id=game_id))
if not state.enabled:
return result
xp = dict(state.affinity_xp or {})
counters = dict(stats)
counters["wins"] = int(won)
for row in session.query(DailyQuest).filter_by(account_id=account_id, status="active"):
if row.accepted_at is None or row.accepted_at > started_at:
continue
delta = max(0, int(counters.get(row.definition["stat"], 0)))
if not delta:
continue
row.activations = min(row.definition["target"], row.activations + delta)
payload = quest_payload(row)
if row.activations < row.definition["target"]:
result["progressedQuests"].append(payload)
continue
row.status = "completed"
old_xp = xp.get("Colorless", 0)
new_xp = min(max(XP_LEVELS.values()), old_xp + row.definition["xp"])
xp["Colorless"] = new_xp
result["completedQuestsAndXPTotal"].append([payload["questDefinition"], new_xp])
result["rewards"].extend(payload["questDefinition"]["rewards"])
for level, threshold in XP_LEVELS.items():
if old_xp < threshold <= new_xp:
result["rewards"].append(coin_reward(25, f"ColorlessLevel{level}"))
state.affinity_xp = xp
coins = sum(reward["rewardAmount"] for reward in result["rewards"])
if coins:
wallet = session.get(Wallet, account_id)
if wallet is None:
wallet = Wallet(account_id=account_id, coins=0, gems=0, tickets=0)
session.add(wallet)
wallet.coins += coins
return result

View File

@@ -1,5 +1,6 @@
from spirit.game.attributes import AttrID
from spirit.database import versus_data
from spirit.database.quests import account_quest_attributes
from spirit.database.player_data import get_account_settings, get_screen_name, merge_account_settings
from spirit.game.season_manager import VersusSeasonManager
@@ -23,6 +24,7 @@ def build_account_attributes(account_id):
"""Full SerializableAccount attribute list (AccountUpdated ReplaceWith
swaps the whole set, so every sender must include everything)."""
quest_state = account_quest_attributes(account_id)
season = VersusSeasonManager().get_active_season()
points, all_time = versus_data.get_progress(
account_id, season.season_id if season else "")
@@ -40,4 +42,6 @@ def build_account_attributes(account_id):
{"name": AttrID.DECK_SHARE_MODE.value, "value": "Everybody"},
{"name": AttrID.SEASON_POINTS.value, "value": points},
{"name": AttrID.ALL_TIME_SEASON_POINTS.value, "value": all_time},
{"name": AttrID.QUESTS_ENABLED.value, "value": quest_state["enabled"]},
{"name": AttrID.QUEST_AFFINITY_XP.value, "value": quest_state["xp"]},
]

View File

@@ -140,6 +140,10 @@ class AttrID(IntEnum):
SEASON_POINTS = 201810
ALL_TIME_SEASON_POINTS = 201840
# Native daily challenges and affinity levels
QUESTS_ENABLED = 201800
QUEST_AFFINITY_XP = 201790
# Server-persisted client settings dict (Dictionary<int,int>; K.L.GetSetting(n))
ACCOUNT_SETTINGS = 10230

75
spirit/game/quests.py Normal file
View File

@@ -0,0 +1,75 @@
"""Daily challenge definitions and recovered client payloads."""
import json
from functools import lru_cache
from pathlib import Path
CATALOG_PATH = Path(__file__).resolve().parents[1] / "database/json_data/daily_challenges.json"
MAX_ACTIVE = 3
XP_LEVELS = {1: 5, 2: 10, 3: 20, 4: 35, 5: 50}
AFFINITIES = "Colorless Darkness Dragon Fairy Fighting Fire Grass Lightning Metal Psychic Water".split()
STATS = {"wins", "damagedealt", "energyplayed", "trainersplayed", "prizecardstaken"}
@lru_cache(maxsize=1)
def load_catalog():
"""Loads validated challenge templates; restart the server after editing."""
rows = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
keys = set()
for row in rows:
if not isinstance(row, dict) or not isinstance(row.get("key"), str) or not row["key"]:
raise ValueError("Each daily challenge needs a nonempty key")
if row["key"] in keys or row.get("stat") not in STATS:
raise ValueError(f"Duplicate key or unsupported stat: {row['key']}")
keys.add(row["key"])
for name in ("title", "description"):
if not isinstance(row.get(name), str) or not row[name]:
raise ValueError(f"Challenge {row['key']} needs {name}")
for name, minimum in (("target", 1), ("coins", 1), ("xp", 0)):
if type(row.get(name)) is not int or not minimum <= row[name] <= 100000:
raise ValueError(f"Invalid {name} for challenge {row['key']}")
if len(rows) < 2:
raise ValueError("Daily challenges need at least two templates")
return rows
def coin_reward(amount, source=""):
return {
"name": "DailyChallengeReward", "rewardType": "Tokens", "rewardAmount": amount,
"rewardProductID": None, "rewardCurrency": "Tokens",
"rewardDescription": {"id": "QuestCompleted"}, "rewardReason": "QuestCompleted",
"selectedFrom": [], "selectedIndex": 0, "rewardSource": source,
"index": 0, "openedReward": None,
}
def quest_payload(row):
template = row.definition
return {
"questDefinition": {
"questID": row.quest_id, "name": f"SpiritDaily_{row.quest_id}",
"title": {"id": template["title"]}, "description": {"id": template["description"]},
"rewards": [coin_reward(template["coins"], row.quest_id)],
"affinity": "Colorless", "xp": template["xp"], "tier": 1,
"startTime": None, "endTime": None, "priority": "Bronze", "abandonable": True,
"gameModes": ["PvPMatch", "TournamentMatch"],
"questInformation": {"id": "Progress counts in multiplayer matches started after accepting. Unfinished challenges carry over; choose one new challenge per UTC day."},
},
"currentProgress": {
"questID": row.quest_id, "activations": row.activations,
"requiredActivations": template["target"],
},
}
def configuration(next_available_ms):
return {
"xpLevelMap": {str(k): v for k, v in XP_LEVELS.items()},
"levelTierMap": {"0": 1, "3": 1, "5": 1, "8": 1, "10": 1},
"affinityToAffinityLevelRewardsMap": {
affinity: {str(level): [coin_reward(25)] for level in XP_LEVELS}
for affinity in AFFINITIES
},
"nextQuestAvailableTime": next_available_ms,
"levelActiveQuestsMap": {"1": -1, "2": -1, "3": 2147483647},
}

View File

@@ -2897,7 +2897,7 @@ class GameSession:
profile = getattr(handler, "player", None) if handler else None
if profile is None or getattr(profile, "wallet", None) is None:
continue
profile.wallet.refresh_wallet()
await run_db(profile.wallet.refresh_wallet)
await player.send_packet(
OutboundMsg.CURRENT_WALLET.value, profile.get_wallet_data()
)
@@ -2947,6 +2947,7 @@ class GameSession:
"index": 0,
"openedReward": None,
})
reward_list.extend(await self._progress_daily_challenges(pid, player, winner_id, reason))
envelope = self._sequence_envelope(
EMPTY_SEQUENCE_ID,
self._build_msg(
@@ -2974,6 +2975,29 @@ class GameSession:
self.declare_winner(winner_id, reason)
raise GameOver()
async def _progress_daily_challenges(self, pid, player, winner_id, reason):
"""Credits multiplayer results before the client's end-game quest animation."""
if not isinstance(player, NetworkPlayer) or self.pairing.get("is_solo"):
return []
if len({p.account_id for p in self.players.values() if isinstance(p, NetworkPlayer)}) < 2:
return []
if reason == "A game error occurred." or self.turn_state.turn_number == 0:
return []
from spirit.database.quests import credit_match
try:
result = await run_db(credit_match, player.account_id, self.game_id,
self.game_stats.get(pid, {}), pid == winner_id,
self.match_started_at)
if result["completedQuestsAndXPTotal"] or result["progressedQuests"]:
payload = {key: result[key] for key in ("completedQuestsAndXPTotal", "progressedQuests")}
envelope = self._sequence_envelope(EMPTY_SEQUENCE_ID, self._build_msg(
OutboundMsg.QUESTS_PROGRESSED.value, payload))
await player.send_packet(OutboundMsg.SEQUENCE_MESSAGE.value, envelope)
return result["rewards"]
except Exception:
logging.exception("[Session %s] Daily challenge progress failed for %s", self.game_id, pid)
return []
async def _record_legacy_tournament_result(self, winner_id: str):
"""Advances the live Events-scene bracket this game belonged to."""
ctx = self.pairing.get("legacy_tournament")

View File

@@ -66,6 +66,8 @@ def main():
# Prime the tournament cache off-loop (handlers only read it afterwards)
from spirit.game.tournament_manager import TournamentManager
TournamentManager()
from spirit.game.quests import load_catalog
load_catalog()
# 1. Ensure asset_map.json exists for first-time setup
map_path = "spirit/server/asset_map.json"

View File

@@ -173,6 +173,9 @@ class InboundMsg(str, Enum):
# Source: GetQuests
GET_QUESTS = "GetQuests"
GET_QUEST_CONFIGURATION_DATA = "GetQuestConfigurationData"
QUESTS_SELECTED = "QuestsSelected"
ABANDON_QUESTS = "AbandonQuests"
# Source: GetPokemonFamilyMap
GET_POKEMON_FAMILY_MAP = "GetPokemonFamilyMap"
@@ -537,8 +540,9 @@ class OutboundMsg(str, Enum):
NOTIFY_JOIN = "NotifyJoin"
NOTIFY_LEAVE = "NotifyLeave"
# Source: Quests.cs (Guessed)
QUESTS = "Quests"
# Source: pie/AllQuests.cs and pie/QuestsProgressed.cs.
ALL_QUESTS = "AllQuests"
QUESTS_PROGRESSED = "QuestsProgressed"
# Social Notifications
NOTIFICATION = "Notification"

View File

@@ -6,6 +6,7 @@ from spirit.database.accounts import get_account_by_username, create_account, ve
from spirit.game.attributes import AttrID
from spirit.game.season_manager import VersusSeasonManager
from spirit.game.account_attributes import build_account_attributes, anchor_versus_animation
from spirit.database.quests import get_quests
from .base import BaseHandler, handle
from spirit.server.state import consume_ticket, sweep_expired_tickets
from .social import SocialHandler
@@ -161,9 +162,10 @@ class AuthHandler(BaseHandler):
anchor_versus_animation(account_id)
attributes = build_account_attributes(account_id)
daily_info = process_daily_login(account_id)
return player, attributes, daily_info
quest_snapshot = get_quests(account_id)
return player, attributes, daily_info, quest_snapshot
player, account_attributes, daily_info = await run_db(_load_login_state)
player, account_attributes, daily_info, quest_snapshot = await run_db(_load_login_state)
self.client.player = player
# Index the authenticated client so presence/challenge/login-guard are O(1).
self.client.server.register_account(self.client)
@@ -269,13 +271,7 @@ class AuthHandler(BaseHandler):
# QuestConfigurationUpdated
await self.client.send_packet({
"messageName": OutboundMsg.QUEST_CONFIGURATION_UPDATED.value,
"questConfiguration": {
"xpLevelMap": {"1": 100, "2": 200},
"levelTierMap": {"1": 1},
"affinityToAffinityLevelRewardsMap": {},
"nextQuestAvailableTime": 4102444800000,
"levelActiveQuestsMap": {"1": 1}
}
"questConfiguration": quest_snapshot["configuration"]
}, 0)
# DailyLogin — weeksRewards outer array = days; timestamp = next reward time in ms

View File

@@ -0,0 +1,70 @@
"""Native daily challenge request handlers."""
from spirit.database import quests
from spirit.database.async_utils import run_db
from spirit.game.account_attributes import build_account_attributes
from spirit.network.message_names import InboundMsg, OutboundMsg
from .base import BaseHandler, handle
class QuestHandler(BaseHandler):
async def _send_snapshot(self, snapshot, request_id):
await self.send({"messageName": OutboundMsg.QUEST_CONFIGURATION_UPDATED.value,
"questConfiguration": snapshot["configuration"]})
await self.send({"messageName": OutboundMsg.ALL_QUESTS.value,
**snapshot["quests"]}, request_id)
@handle(InboundMsg.GET_QUESTS)
async def handle_get_quests(self, message, request_id, flags):
if self.client.player is None:
return
snapshot = await run_db(quests.get_quests, self.client.player.account_id)
await self._send_snapshot(snapshot, request_id)
@handle(InboundMsg.GET_QUEST_CONFIGURATION_DATA)
async def handle_get_configuration(self, message, request_id, flags):
if self.client.player is None:
return
snapshot = await run_db(quests.get_quests, self.client.player.account_id)
await self.send({"messageName": OutboundMsg.QUEST_CONFIGURATION_UPDATED.value,
"questConfiguration": snapshot["configuration"]}, request_id)
@handle(InboundMsg.QUESTS_ENABLED)
async def handle_quests_enabled(self, message, request_id, flags):
if self.client.player is None or not isinstance(message, dict):
return
enabled = message.get("enabled")
if type(enabled) is not bool:
return await self.handle_get_quests(message, request_id, flags)
player = self.client.player
def update_enabled():
snapshot = quests.set_enabled(player.account_id, enabled)
return snapshot, build_account_attributes(player.account_id)
snapshot, attributes = await run_db(update_enabled)
await self.send({"messageName": OutboundMsg.ACCOUNT_UPDATED.value, "account": {
"username": player.username, "accountID": player.account_id, "attributes": attributes,
}})
await self._send_snapshot(snapshot, request_id)
@staticmethod
def _quest_ids(message):
ids = message.get("quests") if isinstance(message, dict) else None
return ids if isinstance(ids, list) and all(isinstance(i, str) for i in ids) else []
@handle(InboundMsg.QUESTS_SELECTED)
async def handle_selected(self, message, request_id, flags):
if self.client.player is None:
return
snapshot = await run_db(quests.select_quest, self.client.player.account_id,
self._quest_ids(message))
await self._send_snapshot(snapshot, request_id)
@handle(InboundMsg.ABANDON_QUESTS)
async def handle_abandoned(self, message, request_id, flags):
if self.client.player is None:
return
snapshot = await run_db(quests.abandon_quests, self.client.player.account_id,
self._quest_ids(message))
await self._send_snapshot(snapshot, request_id)

View File

@@ -135,11 +135,6 @@ class SocialHandler(BaseHandler):
for member in self._room_member_clients(room_id):
await member.send_packet(notify, 0)
@handle(InboundMsg.QUESTS_ENABLED)
async def handle_quests_enabled(self, message, request_id, flags):
logging.info(f"[TCP] [{self.client.addr}] Client checking if Quests are enabled.")
pass
@handle(InboundMsg.GET_FRIEND_ROSTER)
async def handle_get_friend_roster(self, message, request_id, flags):
logging.info(f"[TCP] [{self.client.addr}] Client requested Friend Roster.")
@@ -309,11 +304,3 @@ class SocialHandler(BaseHandler):
"messageName": OutboundMsg.FRIEND_ERROR.value,
"error": message
}, request_id)
@handle(InboundMsg.GET_QUESTS)
async def handle_get_quests(self, message, request_id, flags):
logging.info(f"[TCP] [{self.client.addr}] Client requested Quests.")
await self.send({
"messageName": OutboundMsg.QUESTS.value,
"quests": []
}, request_id)

View File

@@ -14,6 +14,7 @@ from .handlers.trade import TradeHandler
from .handlers.matchmaking import MatchmakingHandler
from .handlers.gameplay import GameplayHandler
from .handlers.tournaments import TournamentHandler
from .handlers.quests import QuestHandler
HANDLER_CLASSES = [
HandshakeHandler,
@@ -27,6 +28,7 @@ HANDLER_CLASSES = [
MatchmakingHandler,
GameplayHandler,
TournamentHandler,
QuestHandler,
]
class PacketRouter: