diff --git a/core/data/alembic/versions/263169f3a129_implement_gameloginapi_gamelogoutapi.py b/core/data/alembic/versions/263169f3a129_implement_gameloginapi_gamelogoutapi.py new file mode 100644 index 0000000..401513e --- /dev/null +++ b/core/data/alembic/versions/263169f3a129_implement_gameloginapi_gamelogoutapi.py @@ -0,0 +1,36 @@ +"""Implement GameLoginApi/GameLogoutApi + +Revision ID: 263169f3a129 +Revises: 48f4acc43a7e +Create Date: 2024-06-23 12:16:57.881129 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '263169f3a129' +down_revision = '48f4acc43a7e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('aime_user_game_locks', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user', sa.Integer(), nullable=False), + sa.Column('game', sa.String(length=4), nullable=False), + sa.Column('expires_at', sa.TIMESTAMP(), server_default=sa.text('date_add(now(), INTERVAL 15 MINUTE)'), nullable=True), + sa.Column('extra', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user', 'game', name='aime_user_title_locks'), + mysql_charset='utf8mb4', + ) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_table("aime_user_game_locks") diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 8c3695c..127d8bf 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -1,7 +1,9 @@ from typing import Optional, List -from sqlalchemy import Table, Column -from sqlalchemy.types import Integer, String, TIMESTAMP +from sqlalchemy import Table, Column, text, UniqueConstraint +from sqlalchemy.dialects import mysql +from sqlalchemy.types import Integer, String, TIMESTAMP, JSON from sqlalchemy.sql import func +from sqlalchemy.sql.schema import ForeignKey from sqlalchemy.dialects.mysql import insert from sqlalchemy.sql import func, select from sqlalchemy.engine import Row @@ -23,6 +25,18 @@ aime_user = Table( mysql_charset="utf8mb4", ) +game_locks = Table( + "aime_user_game_locks", + metadata, + Column("id", Integer, nullable=False, primary_key=True, autoincrement=True), + Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("game", String(4), nullable=False), + Column("expires_at", TIMESTAMP, server_default=func.date_add(func.now(), text("INTERVAL 15 MINUTE"))), + Column("extra", JSON), + UniqueConstraint("user", "game", name="aime_user_title_locks"), + mysql_charset="utf8mb4", +) + class UserData(BaseData): async def create_user( self, @@ -123,4 +137,33 @@ class UserData(BaseData): async def get_user_by_username(self, username: str) -> Optional[Row]: result = await self.execute(aime_user.select(aime_user.c.username == username)) - if result: return result.fetchone() + if result: + return result.fetchone() + + async def acquire_lock_for_game(self, user_id: int, game: str, extra: dict | None = None): + sql = game_locks.select( + (game_locks.c.user == user_id) + & (game_locks.c.game == game) + & func.timestampdiff(text("SECOND"), func.now(), game_locks.c.expires_at) > 0) + result = await self.execute(sql) + + if result: + return result.fetchone() + + sql = ( + insert(game_locks) + .values(user=user_id, game=game, extra=extra) + .on_duplicate_key_update( + expires_at=func.date_add(func.now(), text("INTERVAL 15 MINUTE")), + extra=extra, + ) + ) + + await self.execute(sql) + + return None + + async def release_lock_for_game(self, user_id: int, game: str): + sql = game_locks.delete(game_locks.c.user == user_id & game_locks.c.game == game) + + await self.execute(sql) diff --git a/titles/chuni/base.py b/titles/chuni/base.py index b83d7ce..6cd568a 100644 --- a/titles/chuni/base.py +++ b/titles/chuni/base.py @@ -31,12 +31,21 @@ class ChuniBase: loginBonus 30 gets looped, only show the login banner every 24 hours, adds the bonus to items (itemKind 6) """ + user_id = int(data["userId"]) + lock_result = await self.data.user.acquire_lock_for_game(user_id, self.game, data) + + if lock_result is not None: + self.logger.warn( + "User ID %d attempted to log in while having a profile lock expiring on %s.", + user_id, lock_result["expires_at"], + extra=lock_result["extra"] + ) + return {"returnCode": 0} # ignore the login bonus if disabled in config if not self.game_cfg.mods.use_login_bonus: return {"returnCode": 1} - user_id = data["userId"] login_bonus_presets = await self.data.static.get_login_bonus_presets(self.version) for preset in login_bonus_presets: @@ -120,6 +129,9 @@ class ChuniBase: async def handle_game_logout_api_request(self, data: Dict) -> Dict: # self.data.base.log_event("chuni", "logout", logging.INFO, {"version": self.version, "user": data["userId"]}) + user_id = int(data["userId"]) + + await self.data.user.release_lock_for_game(user_id, self.game) return {"returnCode": 1} async def handle_get_game_charge_api_request(self, data: Dict) -> Dict: