diff --git a/core/adb_handlers/campaign.py b/core/adb_handlers/campaign.py index a1a372e..c2ac1da 100644 --- a/core/adb_handlers/campaign.py +++ b/core/adb_handlers/campaign.py @@ -13,7 +13,8 @@ class Campaign: self.distrib_end_date = 0 def make(self) -> bytes: - name_padding = bytes(128 - len(self.name)) + name_enc = self.name.encode() + name_padding = bytes(128 - len(name_enc)) return Struct( "id" / Int32ul, "name" / Bytes(128), @@ -25,7 +26,7 @@ class Campaign: Padding(8), ).build(dict( id = self.id, - name = self.name.encode() + name_padding, + name = name_enc + name_padding, announce_date = self.announce_date, start_date = self.start_date, end_date = self.end_date, diff --git a/core/aimedb.py b/core/aimedb.py index ea2ff4a..3926b73 100644 --- a/core/aimedb.py +++ b/core/aimedb.py @@ -8,6 +8,10 @@ from core.config import CoreConfig from core.utils import create_sega_auth_key from core.data import Data from .adb_handlers import * +from datetime import datetime, timedelta + +def mku32date(tm: datetime) -> int: + return ((tm.day * 100) + tm.hour) + ((((tm.year - 1900) * 100) + tm.month) * 10000) class AimedbServlette(): request_list: Dict[int, Tuple[Callable[[bytes, int], Union[ADBBaseResponse, bytes]], int, str]] = {} @@ -164,14 +168,37 @@ class AimedbServlette(): if h.protocol_ver >= 0x3030: req = h resp = ADBCampaignResponse.from_req(req) + campaigns = await self.data.base.get_active_campaigns_by_game(req.game_id) + + if campaigns is not None: + now = datetime.now() + for x in range(len(campaigns)): + c = campaign.Campaign() + c.id = campaigns[x]['id'] + c.name = campaigns[x]['name'] + + c.announce_date = mku32date(campaigns[x]['announce_date'] or (now - timedelta(days=7))) + c.start_date = mku32date(campaigns[x]['start_date'] or (now - timedelta(days=5))) + c.end_date = mku32date(campaigns[x]['end_date'] or (now + timedelta(days=5))) + c.distrib_start_date = mku32date(campaigns[x]['distrib_start_date'] or (now - timedelta(days=4))) + c.distrib_end_date = mku32date(campaigns[x]['distrib_end_date'] or (now + timedelta(days=7))) + + resp.campaigns[x] = c + if x >= 2: break else: req = ADBOldCampaignRequest(data) self.logger.info(f"Legacy campaign request for campaign {req.campaign_id} (protocol version {hex(h.protocol_ver)})") resp = ADBOldCampaignResponse.from_req(req.head) + + camp = await self.data.base.get_campaign_by_id(req.campaign_id) + if camp is not None: + resp.info0 = camp["info0"] or 0 + resp.info1 = camp["info1"] or 0 + resp.info2 = camp["info2"] or 0 + resp.info3 = camp["info3"] or 0 - # We don't currently support campaigns return resp async def handle_lookup(self, data: bytes, resp_code: int) -> ADBBaseResponse: @@ -373,8 +400,19 @@ class AimedbServlette(): req = ADBCampaignClearRequest(data) resp = ADBCampaignClearResponse.from_req(req.head) + + campaigns = await self.data.user.get_user_active_campaign_progress_by_game(req.aime_id, req.head.game_id) + if campaigns is not None: + for x in range(len(campaigns)): + c = campaign.CampaignClear() + + c.id = campaigns[x]['campaign_id'] + c.entry_flag = campaigns[x]['is_participating'] + c.clear_flag = campaigns[x]['progress'] + + resp.campaign_clear_status[x] = c + if x >= 2: break - # We don't support campaign stuff return resp async def handle_register(self, data: bytes, resp_code: int) -> bytes: diff --git a/core/data/alembic/versions/1b78db5898f4_add_campaigns.py b/core/data/alembic/versions/1b78db5898f4_add_campaigns.py new file mode 100644 index 0000000..847cd68 --- /dev/null +++ b/core/data/alembic/versions/1b78db5898f4_add_campaigns.py @@ -0,0 +1,63 @@ +"""add_campaigns + +Revision ID: 1b78db5898f4 +Revises: ada3e2d02483 +Create Date: 2026-07-05 16:46:01.929705 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '1b78db5898f4' +down_revision = 'ada3e2d02483' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('campaign', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('name', sa.TEXT(length=127), nullable=False), + sa.Column('announce_date', sa.TIMESTAMP(), nullable=True), + sa.Column('start_date', sa.TIMESTAMP(), nullable=True), + sa.Column('end_date', sa.TIMESTAMP(), nullable=True), + sa.Column('distrib_start_date', sa.TIMESTAMP(), nullable=True), + sa.Column('distrib_end_date', sa.TIMESTAMP(), nullable=True), + sa.Column('info0', sa.INTEGER(), nullable=True), + sa.Column('info1', sa.INTEGER(), nullable=True), + sa.Column('info2', sa.INTEGER(), nullable=True), + sa.Column('info3', sa.INTEGER(), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + op.create_table('campaign_game', + sa.Column('campaign_id', sa.INTEGER(), nullable=False), + sa.Column('game_id', sa.TEXT(length=5), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaign.id'], onupdate='cascade', ondelete='cascade'), + sa.UniqueConstraint('campaign_id', 'game_id', name='campaign_game_uk'), + mysql_charset='utf8mb4' + ) + op.create_table('campaign_progress', + sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('campaign_id', sa.INTEGER(), nullable=False), + sa.Column('is_participating', sa.INTEGER(), server_default='0', nullable=False), + sa.Column('progress', sa.INTEGER(), server_default='0', nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaign.id'], onupdate='cascade', ondelete='cascade'), + sa.ForeignKeyConstraint(['user_id'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('campaign_id', 'user_id', name='campaign_progress_uk'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('campaign_progress') + op.drop_table('campaign_game') + op.drop_table('campaign') + # ### end Alembic commands ### diff --git a/core/data/schema/base.py b/core/data/schema/base.py index 80ab74b..7934cd0 100644 --- a/core/data/schema/base.py +++ b/core/data/schema/base.py @@ -3,15 +3,17 @@ import json import logging from random import randrange from typing import Any, Dict, List, Optional +from datetime import datetime -from sqlalchemy import Column, MetaData, Table +from sqlalchemy import Column, MetaData, Table, UniqueConstraint from sqlalchemy.engine import Row from sqlalchemy.engine.cursor import CursorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import ForeignKey -from sqlalchemy.sql import func, text +from sqlalchemy.sql import func, text, and_, or_ +from sqlalchemy.dialects.mysql import insert from sqlalchemy.types import INTEGER, JSON, TEXT, TIMESTAMP, Integer, String from core.config import CoreConfig @@ -37,6 +39,33 @@ event_log: Table = Table( mysql_charset="utf8mb4", ) +campaign: Table = Table( + "campaign", + metadata, + Column("id", INTEGER, primary_key=True, nullable=False), + Column("name", TEXT(127), nullable=False), + Column("announce_date", TIMESTAMP), # None = start announcing now + Column("start_date", TIMESTAMP), # None = start now + Column("end_date", TIMESTAMP), # None = never end + Column("distrib_start_date", TIMESTAMP), # None = start distributing now + Column("distrib_end_date", TIMESTAMP), # None = never stop distributing + # These are for legacy campaign requests prior to ADB v3.03.0. + Column("info0", INTEGER), + Column("info1", INTEGER), + Column("info2", INTEGER), + Column("info3", INTEGER), + mysql_charset="utf8mb4", +) + +# Unfortunatly without a machine table change there's no real way to gate by game version... +campaign_game: Table = Table( + "campaign_game", + metadata, + Column("campaign_id", INTEGER, ForeignKey("campaign.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("game_id", TEXT(5), nullable=False), + UniqueConstraint("campaign_id", "game_id", name="campaign_game_uk"), + mysql_charset="utf8mb4", +) class BaseData: def __init__(self, cfg: CoreConfig, conn: "sessionmaker[AsyncSession]") -> None: @@ -119,6 +148,127 @@ class BaseData: return None return result.fetchall() + async def create_campaign(self, campaign_id: int, name: str, announce_date: Optional[datetime] = None, + start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, + distribute_start_date: Optional[datetime] = None, distribute_end_date: Optional[datetime] = None) -> Optional[int]: + sql = insert(campaign).values( + id = campaign_id, + name = name, + announce_date = announce_date, + start_date = start_date, + end_date = end_date, + distribute_start_date = distribute_start_date, + distribute_end_date = distribute_end_date, + ) + + conflict = sql.on_duplicate_key_update( + announce_date = announce_date, + start_date = start_date, + end_date = end_date, + distribute_start_date = distribute_start_date, + distribute_end_date = distribute_end_date, + ) + + result = await self.execute(conflict) + if result is None: + self.logger.error(f"Failed to create campaign {name} ID {campaign_id}!") + return None + return result.lastrowid + + async def create_campaign_old(self, campaign_id: int, info0: int = 0, info1: int = 0, info2: int = 0, info3: int = 0) -> Optional[int]: + sql = insert(campaign).values( + id = campaign_id, + info0 = info0, + info1 = info1, + info2 = info2, + info3 = info3 + ) + + conflict = sql.on_duplicate_key_update( + info0 = info0, + info1 = info1, + info2 = info2, + info3 = info3 + ) + + result = await self.execute(conflict) + if result is None: + self.logger.error(f"Failed to create legacy campaign with ID {campaign_id}!") + return None + return result.lastrowid + + async def add_game_to_campaign(self, campaign_id: int, game_id: str) -> bool: + sql = insert(campaign_game).values( + id = campaign_id, + game_id = game_id + ) + + conflict = sql.on_duplicate_key_do_nothing() + + result = await self.execute(conflict) + if result is None: + self.logger.error(f"Failed to add game {game_id} to campaign {campaign_id}!") + return False + return True + + async def get_campaigns_by_game(self, game_id: str) -> Optional[List[Row]]: + result = await self.execute(campaign + .join(campaign_game, campaign.c.id == campaign_game.c.campaign_id) + .select(campaign_game.c.game_id == game_id)) + + if result is not None: + return result.fetchall() + + async def get_active_campaigns_by_game(self, game_id: str) -> Optional[List[Row]]: + result = await self.execute(campaign + .join(campaign_game, campaign.c.id == campaign_game.c.campaign_id) + .select(and_(campaign_game.c.game_id == game_id, and_( + # TODO: Maybe add a RANK statement to prioritize campaigns that have set end dates? + or_(campaign.c.announce_date >= datetime.now(), campaign.c.announce_date == None), + or_(campaign.c.distrib_end_date < datetime.now(), campaign.c.distrib_end_date == None), + )))) + + if result is not None: + return result.fetchall() + + async def get_campaigns(self) -> Optional[List[Row]]: + result = await self.execute(campaign.select()) + + if result is not None: + return result.fetchall() + + async def get_active_campaigns(self) -> Optional[List[Row]]: + result = await self.execute(campaign + .select(and_( + or_(campaign.c.announce_date >= datetime.now(), campaign.c.announce_date == None), + or_(campaign.c.distrib_end_date < datetime.now(), campaign.c.distrib_end_date == None), + ))) + + if result is not None: + return result.fetchall() + + async def get_campaign_by_id(self, campaign_id: int) -> Optional[Row]: + result = await self.execute(campaign.select(campaign.c.id == campaign_id)) + + if result is not None: + return result.fetchone() + + async def get_games_in_campaign(self, campaign_id: int) -> Optional[List[Row]]: + result = await self.execute(campaign_game.select(campaign_game.c.campaign_id == campaign_id)) + + if result is not None: + return result.fetchall() + + async def is_game_in_campaign(self, game_id: str, campaign_id: int) -> bool: + result = await self.execute(campaign_game.select(and_( + campaign_game.c.campaign_id == campaign_id, + campaign_game.c.game_id == game_id + ))) + + if result is not None: + return result.fetchone() is not None + return False + def fix_bools(self, data: Dict) -> Dict: for k, v in data.items(): if k == "userName" or k == "teamName": diff --git a/core/data/schema/user.py b/core/data/schema/user.py index db6b71e..3e667a0 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -1,13 +1,14 @@ from typing import List, Optional import bcrypt -from sqlalchemy import Column, Table +from sqlalchemy import Column, Table, ForeignKey, UniqueConstraint from sqlalchemy.dialects.mysql import insert from sqlalchemy.engine import Row -from sqlalchemy.sql import func, select -from sqlalchemy.types import TIMESTAMP, Integer, String +from sqlalchemy.sql import func, select, and_, or_ +from sqlalchemy.types import TIMESTAMP, Integer, String, BIGINT, INTEGER +from datetime import datetime -from core.data.schema.base import BaseData, metadata +from core.data.schema.base import BaseData, metadata, campaign, campaign_game aime_user: Table = Table( "aime_user", @@ -23,6 +24,18 @@ aime_user: Table = Table( mysql_charset="utf8mb4", ) +campaign_progress: Table = Table( + "campaign_progress", + metadata, + Column("id", BIGINT, nullable=False, primary_key=True, autoincrement=True), + Column("user_id", Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("campaign_id", INTEGER, ForeignKey("campaign.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("is_participating", INTEGER, nullable=False, server_default="0"), + Column("progress", INTEGER, nullable=False, server_default="0"), + UniqueConstraint("campaign_id", "user_id", name="campaign_progress_uk"), + mysql_charset="utf8mb4", +) + class UserData(BaseData): async def create_user( self, @@ -136,3 +149,81 @@ class UserData(BaseData): result = await self.execute(sql) return result is not None + + async def get_user_campaign_progress_by_campaign(self, user_id: int, campaign_id: int) -> Optional[Row]: + result = await self.execute(campaign_progress + .join(campaign, campaign_progress.c.campaign_id == campaign.c.id) + .select(and_(campaign_progress.c.user_id == user_id, campaign_progress.c.campaign_id == campaign_id))) + + if result is not None: + return result.fetchone() + + async def get_user_all_campaign_progress(self, user_id: int) -> Optional[List[Row]]: + result = await self.execute(campaign_progress + .join(campaign, campaign_progress.c.campaign_id == campaign.c.id) + .select(campaign_progress.c.user_id == user_id)) + + if result is not None: + return result.fetchall() + + async def get_user_active_campaign_progress(self, user_id: int) -> Optional[List[Row]]: + # distrib_end_date instead of end_date to give the games time to distribute the rewards + result = await self.execute(campaign_progress + .join(campaign, campaign_progress.c.campaign_id == campaign.c.id) + .select(and_(campaign_progress.c.user_id == user_id, and_( + or_(campaign.c.start_date >= datetime.now(), campaign.c.start_date == None), + or_(campaign.c.distrib_end_date < datetime.now(), campaign.c.distrib_end_date == None), + )))) + + if result is not None: + return result.fetchall() + + async def get_user_all_campaign_progress_by_game(self, user_id: int, game_id: str) -> Optional[List[Row]]: + result = await self.execute(campaign_progress + .join(campaign, campaign_progress.c.campaign_id == campaign.c.id) + .join(campaign_game, campaign_progress.c.campaign_id == campaign_game.c.campaign_id) + .select(and_(campaign_progress.c.user_id == user_id, campaign_game.c.game_id == game_id))) + + if result is not None: + return result.fetchall() + + async def get_user_active_campaign_progress_by_game(self, user_id: int, game_id: str) -> Optional[List[Row]]: + result = await self.execute(campaign_progress + .join(campaign, campaign_progress.c.campaign_id == campaign.c.id) + .join(campaign_game, campaign_progress.c.campaign_id == campaign_game.c.campaign_id) + .select(and_(and_(campaign_progress.c.user_id == user_id, campaign_game.c.game_id == game_id), and_( + or_(campaign.c.start_date >= datetime.now(), campaign.c.start_date == None), + or_(campaign.c.distrib_end_date < datetime.now(), campaign.c.distrib_end_date == None), + )))) + + if result is not None: + return result.fetchall() + + async def set_user_campaign_is_participating(self, user_id: int, campaign_id: int, is_participating: bool) -> Optional[bool]: + sql = insert(campaign_progress).values( + user_id = user_id, + campaign_id = campaign_id, + is_participating = is_participating + ) + + conflict = sql.on_duplicate_key_update(is_participating = is_participating) + result = await self.execute(conflict) + if result is None: + self.logger.error(f"Failed to update campaign participation status for user {user_id} on campaign {campaign_id}!") + return None + return result.lastrowid + + async def set_user_campaign_progress(self, user_id: int, campaign_id: int, progress: int) -> Optional[bool]: + sql = insert(campaign_progress).values( + user_id = user_id, + campaign_id = campaign_id, + is_participating = 1, + progress = progress + ) + + conflict = sql.on_duplicate_key_update(progress = progress) + result = await self.execute(conflict) + if result is None: + self.logger.error(f"Failed to update campaign progress for user {user_id} on campaign {campaign_id} to {progress}!") + return None + return result.lastrowid