diff --git a/shvatka/core/games/interactors.py b/shvatka/core/games/interactors.py index bd7a73a6..93c5d045 100644 --- a/shvatka/core/games/interactors.py +++ b/shvatka/core/games/interactors.py @@ -84,7 +84,7 @@ async def __call__(self, user: dto.User) -> CurrentHints: level_time = await self.dao.get_current_level_time(team, game) level = await self.dao.get_level_by_game_and_number(game, level_time.level_number) hints = level.get_hints_for_timedelta(datetime.now(tz=tz_utc) - level_time.start_at) - keys = await self.dao.get_team_typed_keys(game, team, level_time.level_number) + keys = await self.dao.get_team_typed_keys(game, team, level_time) return CurrentHints( hints=hints, typed_keys=keys, diff --git a/shvatka/core/interfaces/dal/game_play.py b/shvatka/core/interfaces/dal/game_play.py index 69ed93a4..f49a790f 100644 --- a/shvatka/core/interfaces/dal/game_play.py +++ b/shvatka/core/interfaces/dal/game_play.py @@ -19,7 +19,7 @@ async def get_poll_msg(self, team: dto.Team, game: dto.Game) -> int | None: class GamePlayerDao(Committer, WaiverChecker, GameOrgsGetter, LevelByTeamGetter, Protocol): - async def is_key_duplicate(self, level: dto.Level, team: dto.Team, key: str) -> bool: + async def is_key_duplicate(self, level: dto.LevelTime, team: dto.Team, key: str) -> bool: raise NotImplementedError async def get_played_teams(self, game: dto.Game) -> Iterable[dto.Team]: @@ -36,7 +36,7 @@ async def get_current_level(self, team: dto.Team, game: dto.Game) -> dto.Level: async def get_correct_typed_keys( self, - level: dto.Level, + level_time: dto.LevelTime, game: dto.Game, team: dto.Team, ) -> set[str]: @@ -46,7 +46,7 @@ async def save_key( self, key: str, team: dto.Team, - level: dto.Level, + level_time: dto.LevelTime, game: dto.Game, player: dto.Player, type_: enums.KeyType, @@ -55,12 +55,20 @@ async def save_key( raise NotImplementedError async def get_team_typed_keys( - self, game: dto.Game, team: dto.Team, level_number: int + self, game: dto.Game, team: dto.Team, level_time: dto.LevelTime ) -> list[dto.KeyTime]: raise NotImplementedError - async def level_up(self, team: dto.Team, level: dto.Level, game: dto.Game) -> None: + async def level_up( + self, team: dto.Team, level: dto.Level, game: dto.Game, next_level_number: int + ) -> None: raise NotImplementedError async def finish(self, game: dto.Game) -> None: raise NotImplementedError + + async def get_next_level(self, level: dto.Level, game: dto.Game) -> dto.Level: + raise NotImplementedError + + async def get_level_by_name(self, level_name: str, game: dto.Game) -> dto.Level | None: + raise NotImplementedError diff --git a/shvatka/core/interfaces/dal/key_log.py b/shvatka/core/interfaces/dal/key_log.py index 4af70c7c..095dcce4 100644 --- a/shvatka/core/interfaces/dal/key_log.py +++ b/shvatka/core/interfaces/dal/key_log.py @@ -10,7 +10,7 @@ async def get_typed_keys_grouped(self, game: dto.Game) -> dict[dto.Team, list[dt class GameTeamKeyGetter(Protocol): async def get_team_typed_keys( - self, game: dto.Game, team: dto.Team, level_number: int + self, game: dto.Game, team: dto.Team, level_time: dto.LevelTime ) -> list[dto.KeyTime]: raise NotImplementedError diff --git a/shvatka/core/interfaces/dal/level_times.py b/shvatka/core/interfaces/dal/level_times.py index 08e0a7b7..0b157f0f 100644 --- a/shvatka/core/interfaces/dal/level_times.py +++ b/shvatka/core/interfaces/dal/level_times.py @@ -11,12 +11,12 @@ async def set_game_started(self, game: dto.Game) -> None: async def get_played_teams(self, game: dto.Game) -> Iterable[dto.Team]: raise NotImplementedError - async def set_teams_to_first_level(self, game: dto.Game, teams: Iterable[dto.Team]) -> None: - raise NotImplementedError - - -class LevelTimeChecker(Protocol): - async def is_team_on_level(self, team: dto.Team, level: dto.Level) -> bool: + async def set_to_level( + self, + team: dto.Team, + game: dto.Game, + level_number: int, + ) -> dto.LevelTime: raise NotImplementedError diff --git a/shvatka/core/interfaces/scheduler/sheduler.py b/shvatka/core/interfaces/scheduler/sheduler.py index b122e3d7..f40026e1 100644 --- a/shvatka/core/interfaces/scheduler/sheduler.py +++ b/shvatka/core/interfaces/scheduler/sheduler.py @@ -16,6 +16,7 @@ async def plain_hint( level: dto.Level, team: dto.Team, hint_number: int, + lt_id: int, run_at: datetime, ): raise NotImplementedError diff --git a/shvatka/core/models/dto/__init__.py b/shvatka/core/models/dto/__init__.py index 5aa96090..617e0679 100644 --- a/shvatka/core/models/dto/__init__.py +++ b/shvatka/core/models/dto/__init__.py @@ -3,7 +3,7 @@ from .common import DateRange from .forum_team import ForumTeam from .forum_user import ForumUser -from .game import Game, PreviewGame, FullGame, GameResults +from .game import Game, PreviewGame, FullGame, GameResults, GameFinished from .level import Level from .level_testing import ( LevelTestSuite, diff --git a/shvatka/core/models/dto/action/__init__.py b/shvatka/core/models/dto/action/__init__.py index 5da040b9..0f835cf8 100644 --- a/shvatka/core/models/dto/action/__init__.py +++ b/shvatka/core/models/dto/action/__init__.py @@ -12,6 +12,7 @@ BonusKeyDecision, KeyBonusCondition, WrongKeyDecision, + LevelUpDecision, ) from .state_holder import InMemoryStateHolder diff --git a/shvatka/core/models/dto/action/interface.py b/shvatka/core/models/dto/action/interface.py index 20cf00e9..21ccd265 100644 --- a/shvatka/core/models/dto/action/interface.py +++ b/shvatka/core/models/dto/action/interface.py @@ -33,13 +33,21 @@ def get(self, state_class: type[T]) -> T: raise NotImplementedError -class Decision(Protocol): - type: DecisionType - - class DecisionType(enum.StrEnum): NOT_IMPLEMENTED = enum.auto() LEVEL_UP = enum.auto() SIGNIFICANT_ACTION = enum.auto() NO_ACTION = enum.auto() BONUS_TIME = enum.auto() + + +class Decision(Protocol): + type: DecisionType + + def is_level_up(self) -> bool: + return self.type == DecisionType.LEVEL_UP + + +class LevelUpDecision(Decision): + type: typing.Literal[DecisionType.LEVEL_UP] = DecisionType.LEVEL_UP + next_level: str | None = None diff --git a/shvatka/core/models/dto/action/keys.py b/shvatka/core/models/dto/action/keys.py index a0d1f68f..aeb3f545 100644 --- a/shvatka/core/models/dto/action/keys.py +++ b/shvatka/core/models/dto/action/keys.py @@ -3,9 +3,18 @@ from typing import Literal from shvatka.core.models import enums +from shvatka.core.utils.input_validation import is_key_valid from . import StateHolder from .decisions import NotImplementedActionDecision -from .interface import Action, State, Decision, Condition, DecisionType, ConditionType +from .interface import ( + Action, + State, + Decision, + Condition, + DecisionType, + ConditionType, + LevelUpDecision, +) SHKey: typing.TypeAlias = str @@ -15,6 +24,12 @@ class BonusKey: text: SHKey bonus_minutes: float + def __post_init__(self): + if not is_key_valid(self.text): + raise ValueError + if not (-600 < self.bonus_minutes < 60): + raise ValueError("bonus out of available range") + def __eq__(self, other: object) -> bool: if not isinstance(other, BonusKey): return NotImplemented @@ -50,25 +65,29 @@ def key_text(self) -> str: return self.key -@dataclass +@dataclass(kw_only=True) class KeyDecision(Decision): type: DecisionType key_type: enums.KeyType duplicate: bool key: SHKey - def is_level_up(self) -> bool: - return self.type == DecisionType.LEVEL_UP - @property def key_text(self) -> str: return self.key +@dataclass(kw_only=True) +class LevelUpKeyDecision(KeyDecision, LevelUpDecision): + type: typing.Literal[DecisionType.LEVEL_UP] = DecisionType.LEVEL_UP + next_level: str | None = None + + @dataclass class KeyWinCondition(Condition): keys: set[SHKey] type: Literal["WIN_KEY"] = ConditionType.WIN_KEY.name + next_level: str | None = None def check(self, action: Action, state_holder: StateHolder) -> Decision: if not isinstance(action, TypedKeyAction): @@ -79,18 +98,26 @@ def check(self, action: Action, state_holder: StateHolder) -> Decision: return WrongKeyDecision(duplicate=state.is_duplicate(action), key=action.key) if not state.is_duplicate(action): if self._is_all_typed(action, state): - type_ = DecisionType.LEVEL_UP + return LevelUpKeyDecision( + key_type=self._get_key_type(action), # TODO always simple + duplicate=state.is_duplicate(action), + key=action.key, + next_level=self.next_level, + ) else: type_ = DecisionType.SIGNIFICANT_ACTION else: type_ = DecisionType.NO_ACTION return KeyDecision( type=type_, - key_type=enums.KeyType.simple if self._is_correct(action) else enums.KeyType.wrong, + key_type=self._get_key_type(action), # TODO always simple duplicate=state.is_duplicate(action), key=action.key, ) + def _get_key_type(self, action: TypedKeyAction): + return enums.KeyType.simple if self._is_correct(action) else enums.KeyType.wrong + def _is_correct(self, action: TypedKeyAction) -> bool: return action.key in self.keys diff --git a/shvatka/core/models/dto/forum_user.py b/shvatka/core/models/dto/forum_user.py index 7a8c713e..35ad6a2f 100644 --- a/shvatka/core/models/dto/forum_user.py +++ b/shvatka/core/models/dto/forum_user.py @@ -5,9 +5,9 @@ @dataclass class ForumUser: db_id: int - forum_id: int + forum_id: int | None name: str - registered: date + registered: date | None player_id: int @property diff --git a/shvatka/core/models/dto/game.py b/shvatka/core/models/dto/game.py index f7233f05..60f61b09 100644 --- a/shvatka/core/models/dto/game.py +++ b/shvatka/core/models/dto/game.py @@ -133,3 +133,8 @@ class GameResults: published_chanel_id: int | None results_picture_file_id: str | None keys_url: str | None + + +@dataclass +class GameFinished: + game: Game diff --git a/shvatka/core/models/dto/team_player.py b/shvatka/core/models/dto/team_player.py index 2e6ee35e..f1eba920 100644 --- a/shvatka/core/models/dto/team_player.py +++ b/shvatka/core/models/dto/team_player.py @@ -17,7 +17,7 @@ class TeamPlayer: date_joined: datetime date_left: datetime | None role: str - emoji: str + emoji: str | None _can_manage_waivers: bool _can_manage_players: bool diff --git a/shvatka/core/services/game_play.py b/shvatka/core/services/game_play.py index 7566e4a5..09a6f87f 100644 --- a/shvatka/core/services/game_play.py +++ b/shvatka/core/services/game_play.py @@ -4,7 +4,7 @@ from datetime import timedelta, datetime from shvatka.core.interfaces.dal.game_play import GamePreparer, GamePlayerDao -from shvatka.core.interfaces.dal.level_times import GameStarter, LevelTimeChecker +from shvatka.core.interfaces.dal.level_times import GameStarter, LevelByTeamGetter from shvatka.core.interfaces.scheduler import Scheduler from shvatka.core.models import dto, enums from shvatka.core.models.dto import scn @@ -70,13 +70,18 @@ async def start_game( logger.info("game %s started", game.id) teams = await dao.get_played_teams(game) - await dao.set_teams_to_first_level(game, teams) + level_times = {} + for team in teams: + level_times[team.id] = await dao.set_to_level(team=team, game=game, level_number=0) await dao.commit() await asyncio.gather(*[view.send_puzzle(team, game.levels[0]) for team in teams]) await asyncio.gather( - *[schedule_first_hint(scheduler, team, game.levels[0], now) for team in teams] + *[ + schedule_first_hint(scheduler, team, game.levels[0], level_times[team.id].id, now) + for team in teams + ] ) await game_log.log(GameLogEvent(GameLogType.GAME_STARTED, {"game": game.name})) @@ -168,9 +173,16 @@ async def process_level_up( await finish_team(team, game, view, game_log, dao, locker) return next_level = await dao.get_current_level(team, game) + lt = await dao.get_current_level_time(team, game) await view.send_puzzle(team=team, level=next_level) - await schedule_first_hint(scheduler, team, next_level) + await schedule_first_hint( + scheduler=scheduler, + team=team, + next_level=next_level, + lt_id=lt.id, + now=datetime.now(tz=tz_utc), + ) level_up_event = LevelUp( team=team, new_level=next_level, orgs_list=await get_spying_orgs(game, dao) ) @@ -209,9 +221,11 @@ async def finish_team( async def send_hint( level: dto.Level, + lt_id: int, hint_number: int, team: dto.Team, - dao: LevelTimeChecker, + game: dto.Game, + dao: LevelByTeamGetter, view: GameView, scheduler: Scheduler, ): @@ -220,17 +234,22 @@ async def send_hint( Если команда уже на следующем уровне - отправлять не надо. :param level: Подсказка относится к уровню. + :param lt_id: Идентификатор соответствия уровня и команды. :param hint_number: Номер подсказки, которую надо отправить. :param team: Какой команде надо отправить подсказку. + :param game: Текущая игра. :param dao: Слой доступа к данным. :param view: Слой отображения. :param scheduler: Планировщик. """ - if not await dao.is_team_on_level(team, level): + lt = await dao.get_current_level_time(team, game) + if lt.id != lt_id: logger.debug( - "team %s is not on level %s, skip sending hint #%s", + "team %s is not on level %s (should %s, actually %s), skip sending hint #%s", team.id, - level.db_id, + level.number_in_game, + lt_id, + lt.id, hint_number, ) return @@ -248,24 +267,26 @@ async def send_hint( level.get_hint(hint_number), level.get_hint(next_hint_number), ) - await scheduler.plain_hint(level, team, next_hint_number, next_hint_time) + await scheduler.plain_hint(level, team, next_hint_number, lt_id, next_hint_time) async def schedule_first_hint( scheduler: Scheduler, team: dto.Team, next_level: dto.Level, - now: datetime | None = None, + lt_id: int, + now: datetime, ): await scheduler.plain_hint( level=next_level, team=team, hint_number=1, + lt_id=lt_id, run_at=calculate_first_hint_time(next_level, now), ) -def calculate_first_hint_time(next_level: dto.Level, now: datetime | None = None) -> datetime: +def calculate_first_hint_time(next_level: dto.Level, now: datetime) -> datetime: return calculate_next_hint_time(next_level.get_hint(0), next_level.get_hint(1), now) diff --git a/shvatka/core/services/key.py b/shvatka/core/services/key.py index 33c9d6cc..44e50b39 100644 --- a/shvatka/core/services/key.py +++ b/shvatka/core/services/key.py @@ -33,19 +33,17 @@ async def submit_key( team: dto.Team, ) -> dto.InsertedKey | None: async with self.locker(team): - level = await self.dao.get_current_level(team, self.game) - assert level.number_in_game is not None + level_time = await self.dao.get_current_level_time(team, self.game) + lvl = await self.dao.get_current_level(team, self.game) correct_keys = await self.dao.get_correct_typed_keys( - level=level, game=self.game, team=team - ) - all_typed = await self.dao.get_team_typed_keys( - self.game, team, level_number=level.number_in_game + level_time=level_time, game=self.game, team=team ) + all_typed = await self.dao.get_team_typed_keys(self.game, team, level_time=level_time) state = action.InMemoryStateHolder( typed_correct=correct_keys, all_typed={k.text for k in all_typed}, ) - decision = level.scenario.check( + decision = lvl.scenario.check( action=action.TypedKeyAction(key=key), state=state, ) @@ -55,14 +53,19 @@ async def submit_key( saved_key = await self.dao.save_key( key=decision.key_text, team=team, - level=level, + level_time=level_time, game=self.game, player=player, type_=decision.key_type, is_duplicate=decision.duplicate, ) - if is_level_up := decision.type == action.DecisionType.LEVEL_UP: - await self.dao.level_up(team=team, level=level, game=self.game) + if is_level_up := isinstance(decision, action.LevelUpDecision): + await self.dao.level_up( + team=team, + level=lvl, + game=self.game, + next_level_number=await self.define_next_level(lvl, decision.next_level), + ) await self.dao.commit() return dto.InsertedKey.from_key_time( saved_key, is_level_up, parsed_key=decision_to_parsed_key(decision) @@ -74,6 +77,23 @@ async def submit_key( logger.warning("impossible decision here is %s", type(decision)) return None + async def define_next_level(self, level: dto.Level, level_name: str | None = None) -> int: + if level_name is None: + assert level.number_in_game is not None + if len(self.game.levels) == level.number_in_game + 1: + return level.number_in_game + 1 + next_level_ = await self.dao.get_next_level(level, self.game) + assert next_level_.number_in_game is not None + return next_level_.number_in_game + else: + next_level = await self.dao.get_level_by_name(level_name, self.game) + if next_level is None: + raise exceptions.ScenarioNotCorrect( + text="Level name not found", name_id=level_name + ) + assert next_level.number_in_game is not None + return next_level.number_in_game + def decision_to_parsed_key( decision: action.KeyDecision | action.BonusKeyDecision | action.WrongKeyDecision, diff --git a/shvatka/core/services/scenario/scn_zip.py b/shvatka/core/services/scenario/scn_zip.py index dc979c3e..29ce3504 100644 --- a/shvatka/core/services/scenario/scn_zip.py +++ b/shvatka/core/services/scenario/scn_zip.py @@ -34,11 +34,9 @@ def unpack_scn(zip_file: ZipPath) -> scn.ParsedZip: def pack_scn(game: scn.RawGameScenario) -> BinaryIO: output = BytesIO() + data = yaml.safe_dump(game.scn, allow_unicode=True, sort_keys=False) with ZipFile(output, "a", ZIP_DEFLATED, False) as zipfile: - zipfile.writestr( - "scn.yaml", - yaml.dump(game.scn, allow_unicode=True, sort_keys=False).encode("utf8"), - ) + zipfile.writestr("scn.yaml", data.encode("utf8")) zipfile.writestr(RESULTS_FILENAME, json.dumps(game.stat, ensure_ascii=False, indent=2)) for guid, content in game.files.items(): zipfile.writestr(guid, content.read()) diff --git a/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py b/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py index e0206bc1..e91d9eaa 100644 --- a/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py +++ b/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py @@ -94,13 +94,18 @@ async def set_results(game: dto.FullGame, results: GameStat, dao: HolderDao): await dao.game.set_start_at(game, game_start_at) await dao.game.set_number(game, results.id) team_getter = get_team_getter(dao.team, results.team_identity) + level_times = [] for team_name, levels in results.results.items(): team = await team_getter(team_name) for level in levels: if level.at is not None: - await dao.level_time.set_to_level( - team, game, level.number, add_timezone(level.at, timezone=tz_utc) + lvl = await dao.level_time.set_to_level( + team, + game, + level.number, + add_timezone(level.at, timezone=tz_utc), ) + level_times.append(lvl) for team_name, keys in results.keys.items(): team = await team_getter(team_name) for i, key in enumerate(keys): # type: int, dto.export_stat.Key @@ -118,7 +123,7 @@ async def set_results(game: dto.FullGame, results: GameStat, dao: HolderDao): team=team, game=game, player=player, - level=game.levels[key.level - 1], + level_time=level_times[key.level - 1], type_=enums.KeyType.simple if is_correct else enums.KeyType.wrong, is_duplicate=False, at=add_timezone(key.at, timezone=tz_utc), diff --git a/shvatka/infrastructure/db/dao/complex/game.py b/shvatka/infrastructure/db/dao/complex/game.py index 3b7d81b6..9f775286 100644 --- a/shvatka/infrastructure/db/dao/complex/game.py +++ b/shvatka/infrastructure/db/dao/complex/game.py @@ -7,7 +7,6 @@ from shvatka.core.interfaces.dal.game import GameUpserter, GameCreator from shvatka.core.models import dto from shvatka.core.models.dto import scn -from shvatka.infrastructure.db.dao import GameDao, LevelDao, FileInfoDao if typing.TYPE_CHECKING: from shvatka.infrastructure.db.dao.holder import HolderDao @@ -15,12 +14,10 @@ @dataclass class GameUpserterImpl(GameUpserter): - game: GameDao - level: LevelDao - file_info: FileInfoDao + dao: "HolderDao" async def upsert_game(self, author: dto.Player, scenario: scn.GameScenario) -> dto.Game: - return await self.game.upsert_game(author, scenario) + return await self.dao.game.upsert_game(author, scenario) async def upsert( self, @@ -29,46 +26,45 @@ async def upsert( game: dto.Game | None = None, no_in_game: int | None = None, ) -> dto.Level: - return await self.level.upsert(author, scenario, game, no_in_game) + return await self.dao.level.upsert(author, scenario, game, no_in_game) async def unlink_all(self, game: dto.Game) -> None: - return await self.level.unlink_all(game) + return await self.dao.level.unlink_all(game) async def upsert_file(self, file: scn.FileMeta, author: dto.Player) -> scn.SavedFileMeta: - return await self.file_info.upsert(file, author) + return await self.dao.file_info.upsert(file, author) async def check_author_can_own_guid(self, author: dto.Player, guid: str) -> None: - return await self.file_info.check_author_can_own_guid(author, guid) + return await self.dao.file_info.check_author_can_own_guid(author, guid) async def is_name_available(self, name: str) -> bool: - return await self.game.is_name_available(name) + return await self.dao.game.is_name_available(name) async def is_author_game_by_name(self, name: str, author: dto.Player) -> bool: - return await self.game.is_author_game_by_name(name, author) + return await self.dao.game.is_author_game_by_name(name, author) async def get_game_by_name(self, name: str, author: dto.Player) -> dto.Game: - return await self.game.get_game_by_name(name=name, author=author) + return await self.dao.game.get_game_by_name(name=name, author=author) async def commit(self) -> None: - await self.level.commit() + await self.dao.commit() @dataclass class GameCreatorImpl(GameCreator): - game: GameDao - level: LevelDao + dao: "HolderDao" async def create_game(self, author: dto.Player, name: str) -> dto.Game: - return await self.game.create_game(author, name) + return await self.dao.game.create_game(author, name) async def link_to_game(self, level: dto.Level, game: dto.Game) -> dto.Level: - return await self.level.link_to_game(level, game) + return await self.dao.level.link_to_game(level, game) async def commit(self) -> None: - return await self.game.commit() + return await self.dao.commit() async def is_name_available(self, name: str) -> bool: - return await self.game.is_name_available(name) + return await self.dao.game.is_name_available(name) @dataclass @@ -150,6 +146,6 @@ async def get_level_by_game_and_number(self, game: dto.Game, number: int) -> dto return await self.dao.level.get_by_number(game, number) async def get_team_typed_keys( - self, game: dto.Game, team: dto.Team, level_number: int + self, game: dto.Game, team: dto.Team, level_time: dto.LevelTime ) -> list[dto.KeyTime]: - return await self.dao.key_time.get_team_typed_keys(game, team, level_number) + return await self.dao.key_time.get_team_typed_keys(game, team, level_time) diff --git a/shvatka/infrastructure/db/dao/complex/game_play.py b/shvatka/infrastructure/db/dao/complex/game_play.py index c451b12f..4ee9507c 100644 --- a/shvatka/infrastructure/db/dao/complex/game_play.py +++ b/shvatka/infrastructure/db/dao/complex/game_play.py @@ -1,82 +1,74 @@ +import typing from dataclasses import dataclass from typing import Iterable + from shvatka.core.interfaces.dal.game_play import GamePreparer, GamePlayerDao from shvatka.core.interfaces.dal.level_times import GameStarter from shvatka.core.models import dto, enums -from shvatka.infrastructure.db.dao import ( - PollDao, - WaiverDao, - OrganizerDao, - GameDao, - LevelTimeDao, - LevelDao, - KeyTimeDao, -) + +if typing.TYPE_CHECKING: + from shvatka.infrastructure.db.dao.holder import HolderDao @dataclass class GamePreparerImpl(GamePreparer): - poll: PollDao - waiver: WaiverDao - org: OrganizerDao + dao: "HolderDao" async def delete_poll_data(self) -> None: - return await self.poll.delete_all() + return await self.dao.poll.delete_all() async def get_agree_teams(self, game: dto.Game) -> Iterable[dto.Team]: - return await self.waiver.get_played_teams(game) + return await self.dao.waiver.get_played_teams(game) async def get_orgs( self, game: dto.Game, with_deleted: bool = False ) -> list[dto.SecondaryOrganizer]: - return await self.org.get_orgs(game) + return await self.dao.organizer.get_orgs(game) async def get_poll_msg(self, team: dto.Team, game: dto.Game) -> int | None: chat_id = team.get_chat_id() assert chat_id is not None - return await self.poll.get_poll_msg_id(chat_id=chat_id, game_id=game.id) + return await self.dao.poll.get_poll_msg_id(chat_id=chat_id, game_id=game.id) @dataclass class GameStarterImpl(GameStarter): - game: GameDao - waiver: WaiverDao - level_times: LevelTimeDao - level: LevelDao + dao: "HolderDao" async def set_game_started(self, game: dto.Game) -> None: - return await self.game.set_started(game) + return await self.dao.game.set_started(game) async def get_played_teams(self, game: dto.Game) -> Iterable[dto.Team]: - return await self.waiver.get_played_teams(game) + return await self.dao.waiver.get_played_teams(game) - async def set_teams_to_first_level(self, game: dto.Game, teams: Iterable[dto.Team]) -> None: - for team in teams: - await self.level_times.set_to_level(team=team, game=game, level_number=0) + async def set_to_level( + self, + team: dto.Team, + game: dto.Game, + level_number: int, + ) -> dto.LevelTime: + return await self.dao.level_time.set_to_level( + team=team, game=game, level_number=level_number + ) async def commit(self) -> None: - await self.game.commit() + await self.dao.commit() @dataclass class GamePlayerDaoImpl(GamePlayerDao): - level_time: LevelTimeDao - level: LevelDao - key_time: KeyTimeDao - waiver: WaiverDao - game: GameDao - organizer: OrganizerDao + dao: "HolderDao" async def check_waiver(self, player: dto.Player, team: dto.Team, game: dto.Game) -> bool: - return await self.waiver.check_waiver(player, team, game) + return await self.dao.waiver.check_waiver(player, team, game) async def is_team_finished(self, team: dto.Team, game: dto.FullGame) -> bool: - level_number = await self.level_time.get_current_level(team, game) + level_number = await self.dao.level_time.get_current_level(team, game) return level_number == len(game.levels) async def get_played_teams(self, game: dto.Game) -> Iterable[dto.Team]: - return await self.waiver.get_played_teams(game) + return await self.dao.waiver.get_played_teams(game) async def is_all_team_finished(self, game: dto.FullGame) -> bool: for team in await self.get_played_teams(game): @@ -84,36 +76,37 @@ async def is_all_team_finished(self, game: dto.FullGame) -> bool: return False return True - async def is_key_duplicate(self, level: dto.Level, team: dto.Team, key: str) -> bool: - return await self.key_time.is_duplicate(level, team, key) + async def is_key_duplicate(self, level: dto.LevelTime, team: dto.Team, key: str) -> bool: + return await self.dao.key_time.is_duplicate(level, team, key) async def get_current_level(self, team: dto.Team, game: dto.Game) -> dto.Level: - return await self.level.get_by_number( - game=game, level_number=await self.level_time.get_current_level(team=team, game=game) + return await self.dao.level.get_by_number( + game=game, + level_number=await self.dao.level_time.get_current_level(team=team, game=game), ) async def get_correct_typed_keys( self, - level: dto.Level, + level_time: dto.LevelTime, game: dto.Game, team: dto.Team, ) -> set[str]: - return await self.key_time.get_correct_typed_keys(level, game, team) + return await self.dao.key_time.get_correct_typed_keys(level_time, game, team) async def save_key( self, key: str, team: dto.Team, - level: dto.Level, + level_time: dto.LevelTime, game: dto.Game, player: dto.Player, type_: enums.KeyType, is_duplicate: bool, ) -> dto.KeyTime: - return await self.key_time.save_key( + return await self.dao.key_time.save_key( key=key, team=team, - level=level, + level_time=level_time, game=game, player=player, type_=type_, @@ -121,28 +114,36 @@ async def save_key( ) async def get_team_typed_keys( - self, game: dto.Game, team: dto.Team, level_number: int + self, game: dto.Game, team: dto.Team, level_time: dto.LevelTime ) -> list[dto.KeyTime]: - return await self.key_time.get_team_typed_keys(game, team, level_number) + return await self.dao.key_time.get_team_typed_keys(game, team, level_time) - async def level_up(self, team: dto.Team, level: dto.Level, game: dto.Game) -> None: - assert level.number_in_game is not None - await self.level_time.set_to_level( + async def level_up( + self, team: dto.Team, level: dto.Level, game: dto.Game, next_level_number: int + ) -> None: + await self.dao.level_time.set_to_level( team=team, game=game, - level_number=level.number_in_game + 1, + level_number=next_level_number, ) async def finish(self, game: dto.Game) -> None: - await self.game.set_finished(game) + await self.dao.game.set_finished(game) async def get_current_level_time(self, team: dto.Team, game: dto.Game) -> dto.LevelTime: - return await self.level_time.get_current_level_time(team=team, game=game) + return await self.dao.level_time.get_current_level_time(team=team, game=game) async def get_orgs( self, game: dto.Game, with_deleted: bool = False ) -> list[dto.SecondaryOrganizer]: - return await self.organizer.get_orgs(game) + return await self.dao.organizer.get_orgs(game) + + async def get_next_level(self, level: dto.Level, game: dto.Game) -> dto.Level: + assert level.number_in_game is not None + return await self.dao.level.get_by_number(game=game, level_number=level.number_in_game + 1) + + async def get_level_by_name(self, level_name: str, game: dto.Game) -> dto.Level | None: + return await self.dao.level.get_by_author_and_name_id(game.author, level_name) async def commit(self) -> None: - await self.key_time.commit() + await self.dao.commit() diff --git a/shvatka/infrastructure/db/dao/complex/level_testing.py b/shvatka/infrastructure/db/dao/complex/level_testing.py index 6c7934e4..97c576e3 100644 --- a/shvatka/infrastructure/db/dao/complex/level_testing.py +++ b/shvatka/infrastructure/db/dao/complex/level_testing.py @@ -1,43 +1,44 @@ +import typing from dataclasses import dataclass from datetime import datetime from shvatka.core.interfaces.dal.level_testing import LevelTestingDao from shvatka.core.models import dto -from shvatka.infrastructure.db.dao import GameDao -from shvatka.infrastructure.db.dao.memory.level_testing import LevelTestingData + +if typing.TYPE_CHECKING: + from shvatka.infrastructure.db.dao.holder import HolderDao @dataclass class LevelTestComplex(LevelTestingDao): - game: GameDao - level_testing: LevelTestingData + dao: "HolderDao" async def save_started_level_test(self, suite: dto.LevelTestSuite, now: datetime): - return await self.level_testing.save_started_level_test(suite, now) + return await self.dao.level_test.save_started_level_test(suite, now) async def is_still_testing(self, suite: dto.LevelTestSuite) -> bool: - return await self.level_testing.is_still_testing(suite) + return await self.dao.level_test.is_still_testing(suite) async def save_key(self, key: str, suite: dto.LevelTestSuite, is_correct: bool): - return await self.level_testing.save_key(key, suite, is_correct) + return await self.dao.level_test.save_key(key, suite, is_correct) async def get_correct_tested_keys(self, suite: dto.LevelTestSuite) -> set[str]: - return await self.level_testing.get_correct_tested_keys(suite) + return await self.dao.level_test.get_correct_tested_keys(suite) async def complete_test(self, suite: dto.LevelTestSuite): - return await self.level_testing.complete_test(suite) + return await self.dao.level_test.complete_test(suite) async def get_testing_result(self, suite: dto.LevelTestSuite) -> dto.LevelTestingResult: - return await self.level_testing.get_testing_result(suite) + return await self.dao.level_test.get_testing_result(suite) async def get_by_id(self, id_: int, author: dto.Player | None = None) -> dto.Game: - return await self.game.get_by_id(id_=id_, author=author) + return await self.dao.game.get_by_id(id_=id_, author=author) async def get_full(self, id_: int) -> dto.FullGame: - return await self.game.get_full(id_=id_) + return await self.dao.game.get_full(id_=id_) async def add_levels(self, game: dto.Game) -> dto.FullGame: - return await self.game.add_levels(game) + return await self.dao.game.add_levels(game) async def commit(self) -> None: - return await self.level_testing.commit() + return await self.dao.level_test.commit() diff --git a/shvatka/infrastructure/db/dao/complex/orgs.py b/shvatka/infrastructure/db/dao/complex/orgs.py index cb985eb9..e87d3dfe 100644 --- a/shvatka/infrastructure/db/dao/complex/orgs.py +++ b/shvatka/infrastructure/db/dao/complex/orgs.py @@ -1,38 +1,39 @@ +import typing from dataclasses import dataclass from shvatka.core.interfaces.dal.organizer import OrgAdder from shvatka.core.models import dto -from shvatka.infrastructure.db.dao import GameDao, OrganizerDao, SecureInvite + +if typing.TYPE_CHECKING: + from shvatka.infrastructure.db.dao.holder import HolderDao @dataclass class OrgAdderImpl(OrgAdder): - game: GameDao - organizer: OrganizerDao - secure_invite: SecureInvite + dao: "HolderDao" async def add_new_org(self, game: dto.Game, player: dto.Player) -> dto.SecondaryOrganizer: - return await self.organizer.add_new(game, player) + return await self.dao.organizer.add_new(game, player) async def commit(self) -> None: - await self.game.commit() + await self.dao.commit() async def get_invite(self, token: str) -> dict: - return await self.secure_invite.get_invite(token) + return await self.dao.secure_invite.get_invite(token) async def remove_invite(self, token: str) -> None: - return await self.secure_invite.remove_invite(token) + return await self.dao.secure_invite.remove_invite(token) async def get_by_id(self, id_: int, author: dto.Player | None = None) -> dto.Game: - return await self.game.get_by_id(id_=id_, author=author) + return await self.dao.game.get_by_id(id_=id_, author=author) async def get_full(self, id_: int) -> dto.FullGame: - return await self.game.get_full(id_=id_) + return await self.dao.game.get_full(id_=id_) async def get_orgs( self, game: dto.Game, with_deleted: bool = False ) -> list[dto.SecondaryOrganizer]: - return await self.organizer.get_orgs(game) + return await self.dao.organizer.get_orgs(game) async def add_levels(self, game: dto.Game) -> dto.FullGame: - return await self.game.add_levels(game) + return await self.dao.game.add_levels(game) diff --git a/shvatka/infrastructure/db/dao/complex/waiver.py b/shvatka/infrastructure/db/dao/complex/waiver.py index a0af7fc8..8eae0f69 100644 --- a/shvatka/infrastructure/db/dao/complex/waiver.py +++ b/shvatka/infrastructure/db/dao/complex/waiver.py @@ -1,17 +1,18 @@ +import typing from dataclasses import dataclass from typing import Iterable, Sequence from shvatka.core.interfaces.dal.waiver import WaiverVoteAdder, WaiverVoteGetter, WaiverApprover from shvatka.core.models import dto from shvatka.core.models.enums.played import Played -from shvatka.infrastructure.db.dao import PollDao, WaiverDao, PlayerDao, TeamPlayerDao + +if typing.TYPE_CHECKING: + from shvatka.infrastructure.db.dao.holder import HolderDao @dataclass class WaiverVoteAdderImpl(WaiverVoteAdder): - poll: PollDao - waiver: WaiverDao - team_player: TeamPlayerDao + dao: "HolderDao" async def is_excluded( self, @@ -19,43 +20,41 @@ async def is_excluded( player: dto.Player, team: dto.Team, ) -> bool: - return await self.waiver.is_excluded(game, player, team) + return await self.dao.waiver.is_excluded(game, player, team) async def add_player_vote(self, team_id: int, player_id: int, vote_var: str) -> None: - return await self.poll.add_player_vote(team_id, player_id, vote_var) + return await self.dao.poll.add_player_vote(team_id, player_id, vote_var) async def get_team_player(self, player: dto.Player) -> dto.TeamPlayer: - return await self.team_player.get_team_player(player) + return await self.dao.team_player.get_team_player(player) @dataclass class WaiverVoteGetterImpl(WaiverVoteGetter): - poll: PollDao - player: PlayerDao + dao: "HolderDao" async def get_dict_player_vote(self, team_id: int) -> dict[int, Played]: - return await self.poll.get_dict_player_vote(team_id) + return await self.dao.poll.get_dict_player_vote(team_id) async def get_by_ids_with_user_and_pit(self, ids: Iterable[int]) -> list[dto.VotedPlayer]: - return await self.player.get_by_ids_with_user_and_pit(ids) + return await self.dao.player.get_by_ids_with_user_and_pit(ids) @dataclass class WaiverApproverImpl(WaiverApprover, WaiverVoteGetterImpl): - waiver: WaiverDao - team_player: TeamPlayerDao + dao: "HolderDao" async def upsert(self, waiver: dto.Waiver) -> None: - return await self.waiver.upsert(waiver) + return await self.dao.waiver.upsert(waiver) async def commit(self) -> None: - return await self.waiver.commit() + return await self.dao.waiver.commit() async def get_team_player(self, player: dto.Player) -> dto.TeamPlayer: - return await self.team_player.get_team_player(player) + return await self.dao.team_player.get_team_player(player) async def get_players(self, team: dto.Team) -> Sequence[dto.FullTeamPlayer]: - return await self.team_player.get_players(team) + return await self.dao.team_player.get_players(team) async def del_player_vote(self, team_id: int, player_id: int) -> None: - return await self.poll.del_player_vote(team_id, player_id) + return await self.dao.poll.del_player_vote(team_id, player_id) diff --git a/shvatka/infrastructure/db/dao/holder.py b/shvatka/infrastructure/db/dao/holder.py index b37f638d..f47aec36 100644 --- a/shvatka/infrastructure/db/dao/holder.py +++ b/shvatka/infrastructure/db/dao/holder.py @@ -83,27 +83,23 @@ async def commit(self): @property def waiver_vote_adder(self) -> WaiverVoteAdder: - return WaiverVoteAdderImpl( - poll=self.poll, waiver=self.waiver, team_player=self.team_player - ) + return WaiverVoteAdderImpl(dao=self) @property def waiver_vote_getter(self) -> WaiverVoteGetter: - return WaiverVoteGetterImpl(poll=self.poll, player=self.player) + return WaiverVoteGetterImpl(dao=self) @property def waiver_approver(self) -> WaiverApprover: - return WaiverApproverImpl( - poll=self.poll, player=self.player, waiver=self.waiver, team_player=self.team_player - ) + return WaiverApproverImpl(dao=self) @property def game_upserter(self) -> GameUpserter: - return GameUpserterImpl(game=self.game, level=self.level, file_info=self.file_info) + return GameUpserterImpl(dao=self) @property def game_creator(self) -> GameCreator: - return GameCreatorImpl(game=self.game, level=self.level) + return GameCreatorImpl(dao=self) @property def game_packager(self) -> GamePackager: @@ -123,33 +119,19 @@ def team_merger(self) -> TeamMerger: @property def game_preparer(self) -> GamePreparer: - return GamePreparerImpl(poll=self.poll, waiver=self.waiver, org=self.organizer) + return GamePreparerImpl(dao=self) @property def game_starter(self) -> GameStarter: - return GameStarterImpl( - game=self.game, - waiver=self.waiver, - level_times=self.level_time, - level=self.level, - ) + return GameStarterImpl(dao=self) @property def game_player(self) -> GamePlayerDao: - return GamePlayerDaoImpl( - level_time=self.level_time, - level=self.level, - key_time=self.key_time, - waiver=self.waiver, - game=self.game, - organizer=self.organizer, - ) + return GamePlayerDaoImpl(dao=self) @property def org_adder(self) -> OrgAdder: - return OrgAdderImpl( - game=self.game, organizer=self.organizer, secure_invite=self.secure_invite - ) + return OrgAdderImpl(dao=self) @property def player_promoter(self) -> PlayerPromoter: @@ -161,7 +143,7 @@ def player_merger(self) -> PlayerMerger: @property def level_testing_complex(self) -> LevelTestingDao: - return LevelTestComplex(level_testing=self.level_test, game=self.game) + return LevelTestComplex(dao=self) @property def game_stat(self) -> GameStatDao: diff --git a/shvatka/infrastructure/db/dao/rdb/level_times.py b/shvatka/infrastructure/db/dao/rdb/level_times.py index f55171d5..3cd74000 100644 --- a/shvatka/infrastructure/db/dao/rdb/level_times.py +++ b/shvatka/infrastructure/db/dao/rdb/level_times.py @@ -18,10 +18,14 @@ def __init__( super().__init__(models.LevelTime, session, clock=clock) async def set_to_level( - self, team: dto.Team, game: dto.Game, level_number: int, at: datetime | None = None - ): + self, + team: dto.Team, + game: dto.Game, + level_number: int, + at: datetime | None = None, + ) -> dto.LevelTime: if at is None: - at = datetime.now(tz=tz_utc) + at = self.clock(tz_utc) level_time = models.LevelTime( game_id=game.id, team_id=team.id, @@ -29,12 +33,8 @@ async def set_to_level( start_at=at, ) self._save(level_time) - - async def is_team_on_level(self, team: dto.Team, level: dto.Level) -> bool: - assert level.game_id is not None - return ( - await self._get_current(team.id, level.game_id) - ).level_number == level.number_in_game + await self._flush(level_time) + return level_time.to_dto(team=team, game=game) async def get_current_level(self, team: dto.Team, game: dto.Game) -> int: return (await self.get_current_level_time(team=team, game=game)).level_number @@ -50,7 +50,7 @@ async def _get_current(self, team_id: int, game_id: int) -> models.LevelTime: models.LevelTime.team_id == team_id, models.LevelTime.game_id == game_id, ) - .order_by(models.LevelTime.level_number.desc()) + .order_by(models.LevelTime.start_at.desc()) .limit(1) ) return result.scalar_one() @@ -71,7 +71,7 @@ async def get_game_level_times(self, game: dto.Game) -> list[dto.LevelTime]: ) .order_by( models.LevelTime.team_id, - models.LevelTime.level_number, + models.LevelTime.start_at, ) ) return [ diff --git a/shvatka/infrastructure/db/dao/rdb/log_keys.py b/shvatka/infrastructure/db/dao/rdb/log_keys.py index f75355f8..00c8d675 100644 --- a/shvatka/infrastructure/db/dao/rdb/log_keys.py +++ b/shvatka/infrastructure/db/dao/rdb/log_keys.py @@ -20,14 +20,14 @@ def __init__( async def get_correct_typed_keys( self, - level: dto.Level, + level_time: dto.LevelTime, game: dto.Game, team: dto.Team, ) -> set[str]: result: ScalarResult[models.KeyTime] = await self.session.scalars( select(models.KeyTime).where( models.KeyTime.game_id == game.id, - models.KeyTime.level_number == level.number_in_game, + models.KeyTime.level_time_id == level_time.id, models.KeyTime.team_id == team.id, models.KeyTime.type_ == enums.KeyType.simple, ) @@ -38,7 +38,7 @@ async def get_team_typed_keys( self, game: dto.Game, team: dto.Team, - level_number: int, + level_time: dto.LevelTime, ) -> list[dto.KeyTime]: result: ScalarResult[models.KeyTime] = await self.session.scalars( select(models.KeyTime) @@ -49,7 +49,7 @@ async def get_team_typed_keys( ) .where( models.KeyTime.game_id == game.id, - models.KeyTime.level_number == level_number, + models.KeyTime.level_time_id == level_time.id, models.KeyTime.team_id == team.id, ) ) @@ -58,12 +58,12 @@ async def get_team_typed_keys( for key in result.all() ] - async def is_duplicate(self, level: dto.Level, team: dto.Team, key: str) -> bool: + async def is_duplicate(self, level_time: dto.LevelTime, team: dto.Team, key: str) -> bool: result: ScalarResult[int] = await self.session.scalars( select(self.model.id) .where( - models.KeyTime.game_id == level.game_id, - models.KeyTime.level_number == level.number_in_game, + models.KeyTime.game_id == level_time.game.id, + models.KeyTime.level_time_id == level_time.id, models.KeyTime.team_id == team.id, models.KeyTime.key_text == key, ) @@ -75,7 +75,7 @@ async def save_key( self, key: str, team: dto.Team, - level: dto.Level, + level_time: dto.LevelTime, game: dto.Game, player: dto.Player, type_: enums.KeyType, @@ -87,7 +87,8 @@ async def save_key( key_time = models.KeyTime( key_text=key, team_id=team.id, - level_number=level.number_in_game, + level_number=level_time.level_number, + level_time_id=level_time.id, game_id=game.id, player_id=player.id, type_=type_, diff --git a/shvatka/infrastructure/db/migrations/README.md b/shvatka/infrastructure/db/migrations/README.md index b30a6fab..3f11aa71 100644 --- a/shvatka/infrastructure/db/migrations/README.md +++ b/shvatka/infrastructure/db/migrations/README.md @@ -1,16 +1,16 @@ Generic single-database configuration with an async dbapi. For create new migration -``` +```sh alembic revision --autogenerate -m "add new table" ``` for migrate -``` +```sh alembic upgrade +1 ``` for rollback -``` +```sh alembic downgrade -1 ``` diff --git a/shvatka/infrastructure/db/migrations/versions/20250224-222423_009b59123fdf_fixes_namings.py b/shvatka/infrastructure/db/migrations/versions/20250224-222423_009b59123fdf_fixes_namings.py new file mode 100644 index 00000000..d905162f --- /dev/null +++ b/shvatka/infrastructure/db/migrations/versions/20250224-222423_009b59123fdf_fixes_namings.py @@ -0,0 +1,106 @@ +"""fixes namings + +Revision ID: 009b59123fdf +Revises: 1659768228ec +Create Date: 2025-02-24 22:24:23.158449 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from shvatka.infrastructure.db.models.level import ScenarioField + +# revision identifiers, used by Alembic. +revision = "009b59123fdf" +down_revision = "1659768228ec" +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + "forum_teams", "name", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=False + ) + op.alter_column( + "forum_teams", "url", existing_type=sa.TEXT(), type_=sa.String(), nullable=False + ) + op.create_unique_constraint(op.f("uq__forum_teams__forum_id"), "forum_teams", ["forum_id"]) + op.alter_column( + "forum_users", "name", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=False + ) + op.alter_column( + "forum_users", "url", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=True + ) + op.alter_column("forum_users", "player_id", existing_type=sa.BIGINT(), nullable=False) + op.alter_column( + "games", "name", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=False + ) + op.alter_column("games", "manage_token", existing_type=sa.TEXT(), nullable=False) + op.alter_column( + "levels", + "scenario", + existing_type=postgresql.JSON(astext_type=sa.Text()), + type_=ScenarioField(astext_type=sa.Text()), + nullable=False, + ) + op.alter_column("levels_times", "level_number", existing_type=sa.INTEGER(), nullable=False) + op.alter_column( + "team_players", "role", existing_type=sa.TEXT(), type_=sa.String(), nullable=False + ) + op.alter_column( + "team_players", "emoji", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=True + ) + op.alter_column( + "teams", "name", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=False + ) + op.alter_column( + "teams", "description", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=True + ) + op.alter_column( + "waivers", "role", existing_type=sa.TEXT(), type_=sa.String(), existing_nullable=True + ) + + +def downgrade(): + op.alter_column( + "waivers", "role", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=True + ) + op.alter_column( + "teams", "description", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=True + ) + op.alter_column( + "teams", "name", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=False + ) + op.alter_column( + "team_players", "emoji", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=True + ) + op.alter_column( + "team_players", "role", existing_type=sa.String(), type_=sa.TEXT(), nullable=True + ) + op.alter_column("levels_times", "level_number", existing_type=sa.INTEGER(), nullable=True) + op.alter_column( + "levels", + "scenario", + existing_type=ScenarioField(astext_type=sa.Text()), + type_=postgresql.JSON(astext_type=sa.Text()), + nullable=True, + ) + op.alter_column("games", "manage_token", existing_type=sa.TEXT(), nullable=True) + op.alter_column( + "games", "name", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=False + ) + op.alter_column("forum_users", "player_id", existing_type=sa.BIGINT(), nullable=True) + op.alter_column( + "forum_users", "url", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=True + ) + op.alter_column( + "forum_users", "name", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=False + ) + op.drop_constraint(op.f("uq__forum_teams__forum_id"), "forum_teams", type_="unique") + op.alter_column( + "forum_teams", "url", existing_type=sa.String(), type_=sa.TEXT(), nullable=True + ) + op.alter_column( + "forum_teams", "name", existing_type=sa.String(), type_=sa.TEXT(), existing_nullable=False + ) diff --git a/shvatka/infrastructure/db/migrations/versions/20250224-223103_149de95bb84e_allow_level_times_cycles.py b/shvatka/infrastructure/db/migrations/versions/20250224-223103_149de95bb84e_allow_level_times_cycles.py new file mode 100644 index 00000000..29b2977e --- /dev/null +++ b/shvatka/infrastructure/db/migrations/versions/20250224-223103_149de95bb84e_allow_level_times_cycles.py @@ -0,0 +1,25 @@ +"""allow level times cycles + +Revision ID: 149de95bb84e +Revises: 009b59123fdf +Create Date: 2025-02-24 22:31:03.293138 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "149de95bb84e" +down_revision = "009b59123fdf" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_constraint("uq__levels_times__game_id", "levels_times", type_="unique") + + +def downgrade(): + op.create_unique_constraint( + "uq__levels_times__game_id", "levels_times", ["game_id", "team_id", "level_number"] + ) diff --git a/shvatka/infrastructure/db/migrations/versions/20250301-024158_f3157300bc04_added_level_time_fk.py b/shvatka/infrastructure/db/migrations/versions/20250301-024158_f3157300bc04_added_level_time_fk.py new file mode 100644 index 00000000..b01bdce1 --- /dev/null +++ b/shvatka/infrastructure/db/migrations/versions/20250301-024158_f3157300bc04_added_level_time_fk.py @@ -0,0 +1,48 @@ +"""added level-time fk + +Revision ID: f3157300bc04 +Revises: 149de95bb84e +Create Date: 2025-03-01 02:41:58.385413 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f3157300bc04" +down_revision = "149de95bb84e" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("log_keys", sa.Column("level_time_id", sa.Integer(), nullable=True)) + # next line update existing log_keys with level_time_id + # by same game_id, team_id and level_number + op.execute( + sa.text( + """ + UPDATE log_keys + SET level_time_id = ( + SELECT id + FROM levels_times + WHERE game_id = log_keys.game_id + AND team_id = log_keys.team_id + AND level_number = log_keys.level_number + AND start_at <= log_keys.enter_time + ORDER BY start_at DESC + LIMIT 1 + ) + WHERE level_time_id IS NULL + """ + ) + ) + op.create_foreign_key( + op.f("log_keys_level_time_id_fkey"), "log_keys", "levels_times", ["level_time_id"], ["id"] + ) + + +def downgrade(): + op.drop_constraint(op.f("log_keys_level_time_id_fkey"), "log_keys", type_="foreignkey") + op.drop_column("log_keys", "level_time_id") diff --git a/shvatka/infrastructure/db/models/forum_team.py b/shvatka/infrastructure/db/models/forum_team.py index 612a88dc..e29a3ea7 100644 --- a/shvatka/infrastructure/db/models/forum_team.py +++ b/shvatka/infrastructure/db/models/forum_team.py @@ -1,4 +1,4 @@ -from sqlalchemy import ForeignKey +from sqlalchemy import ForeignKey, BigInteger from sqlalchemy.orm import relationship, mapped_column, Mapped from shvatka.core.models import dto @@ -8,7 +8,7 @@ class ForumTeam(Base): __tablename__ = "forum_teams" __mapper_args__ = {"eager_defaults": True} - id: Mapped[int] = mapped_column(primary_key=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) forum_id: Mapped[int] = mapped_column(unique=True, nullable=False) name: Mapped[str] = mapped_column(nullable=False, unique=True) url: Mapped[str] diff --git a/shvatka/infrastructure/db/models/forum_user.py b/shvatka/infrastructure/db/models/forum_user.py index d0fe69bb..7582e2d9 100644 --- a/shvatka/infrastructure/db/models/forum_user.py +++ b/shvatka/infrastructure/db/models/forum_user.py @@ -1,6 +1,6 @@ from datetime import date -from sqlalchemy import ForeignKey +from sqlalchemy import BigInteger, ForeignKey from sqlalchemy.orm import relationship, mapped_column, Mapped from shvatka.core.models import dto @@ -10,11 +10,11 @@ class ForumUser(Base): __tablename__ = "forum_users" __mapper_args__ = {"eager_defaults": True} - id: Mapped[int] = mapped_column(primary_key=True) - forum_id: Mapped[int] = mapped_column(unique=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + forum_id: Mapped[int | None] = mapped_column(BigInteger, unique=True) name: Mapped[str] = mapped_column(nullable=False, unique=True) - registered: Mapped[date] - url: Mapped[str] + registered: Mapped[date | None] + url: Mapped[str | None] player_id: Mapped[int] = mapped_column(ForeignKey("players.id"), unique=True) player = relationship( diff --git a/shvatka/infrastructure/db/models/level.py b/shvatka/infrastructure/db/models/level.py index 393085ff..52d37876 100644 --- a/shvatka/infrastructure/db/models/level.py +++ b/shvatka/infrastructure/db/models/level.py @@ -3,8 +3,9 @@ from typing import Any from adaptix import Retort -from sqlalchemy import Integer, Text, ForeignKey, JSON, TypeDecorator, UniqueConstraint +from sqlalchemy import Integer, Text, ForeignKey, TypeDecorator, UniqueConstraint from sqlalchemy.engine import Dialect +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import relationship, mapped_column, Mapped from shvatka.common.factory import REQUIRED_GAME_RECIPES @@ -20,7 +21,7 @@ class ScenarioField(TypeDecorator): - impl = JSON + impl = JSONB cache_ok = True retort = Retort( recipe=[ diff --git a/shvatka/infrastructure/db/models/levels_times.py b/shvatka/infrastructure/db/models/levels_times.py index 474d6ddb..61800bd2 100644 --- a/shvatka/infrastructure/db/models/levels_times.py +++ b/shvatka/infrastructure/db/models/levels_times.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Integer, ForeignKey, DateTime, UniqueConstraint, func +from sqlalchemy import Integer, ForeignKey, DateTime, func from sqlalchemy.orm import relationship, mapped_column, Mapped from shvatka.core.models import dto @@ -32,8 +32,6 @@ class LevelTime(Base): nullable=False, ) - __table_args__ = (UniqueConstraint("game_id", "team_id", "level_number"),) - def to_dto(self, game: dto.Game, team: dto.Team) -> dto.LevelTime: return dto.LevelTime( id=self.id, diff --git a/shvatka/infrastructure/db/models/log_keys.py b/shvatka/infrastructure/db/models/log_keys.py index bd32cbd0..7c1f5114 100644 --- a/shvatka/infrastructure/db/models/log_keys.py +++ b/shvatka/infrastructure/db/models/log_keys.py @@ -31,6 +31,7 @@ class KeyTime(Base): back_populates="log_keys", ) level_number = mapped_column(Integer, nullable=False) + level_time_id = mapped_column(ForeignKey("levels_times.id"), nullable=False) enter_time = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(tz=tz_utc), diff --git a/shvatka/infrastructure/db/models/player.py b/shvatka/infrastructure/db/models/player.py index 45bf10fe..109084c1 100644 --- a/shvatka/infrastructure/db/models/player.py +++ b/shvatka/infrastructure/db/models/player.py @@ -10,7 +10,7 @@ class Player(Base): __mapper_args__ = {"eager_defaults": True} id: Mapped[int] = mapped_column(BigInteger, primary_key=True) can_be_author: Mapped[bool] = mapped_column(Boolean, server_default="f", nullable=False) - promoted_by_id: Mapped[int] = mapped_column(ForeignKey("players.id")) + promoted_by_id: Mapped[int | None] = mapped_column(ForeignKey("players.id")) is_dummy: Mapped[bool] = mapped_column(nullable=False, default=False, server_default="f") user = relationship( "User", diff --git a/shvatka/infrastructure/db/models/team.py b/shvatka/infrastructure/db/models/team.py index 006fa555..6bc7f111 100644 --- a/shvatka/infrastructure/db/models/team.py +++ b/shvatka/infrastructure/db/models/team.py @@ -23,13 +23,13 @@ class Team(Base): back_populates="team", uselist=False, ) - captain_id: Mapped[int] = mapped_column(ForeignKey("players.id")) - captain: Mapped[Player] = relationship( + captain_id: Mapped[int | None] = mapped_column(ForeignKey("players.id")) + captain: Mapped[Player | None] = relationship( "Player", foreign_keys=captain_id, back_populates="captain_by_team", ) - description: Mapped[str] + description: Mapped[str | None] completed_levels = relationship( "LevelTime", diff --git a/shvatka/infrastructure/db/models/team_player.py b/shvatka/infrastructure/db/models/team_player.py index 17eb75e6..068ced39 100644 --- a/shvatka/infrastructure/db/models/team_player.py +++ b/shvatka/infrastructure/db/models/team_player.py @@ -31,7 +31,7 @@ class TeamPlayer(Base): nullable=False, ) role: Mapped[str] - emoji: Mapped[str] + emoji: Mapped[str | None] date_left: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) can_manage_waivers: Mapped[bool] = mapped_column(default=False, nullable=False) diff --git a/shvatka/infrastructure/db/models/waiver.py b/shvatka/infrastructure/db/models/waiver.py index 0df4df5f..8b51ec8d 100644 --- a/shvatka/infrastructure/db/models/waiver.py +++ b/shvatka/infrastructure/db/models/waiver.py @@ -29,7 +29,7 @@ class Waiver(Base): foreign_keys=game_id, back_populates="waivers", ) - role: Mapped[str] + role: Mapped[str | None] played: Mapped[Played] = mapped_column(Enum(Played), nullable=False) def to_dto(self, player: dto.Player, team: dto.Team, game: dto.Game) -> dto.Waiver: diff --git a/shvatka/infrastructure/scheduler/scheduler.py b/shvatka/infrastructure/scheduler/scheduler.py index 94b28a9f..847a29b2 100644 --- a/shvatka/infrastructure/scheduler/scheduler.py +++ b/shvatka/infrastructure/scheduler/scheduler.py @@ -84,11 +84,17 @@ async def plain_hint( level: dto.Level, team: dto.Team, hint_number: int, + lt_id: int, run_at: datetime, ): self.scheduler.add_job( func="shvatka.infrastructure.scheduler.wrappers:send_hint_wrapper", - kwargs={"level_id": level.db_id, "team_id": team.id, "hint_number": hint_number}, + kwargs={ + "level_id": level.db_id, + "team_id": team.id, + "hint_number": hint_number, + "lt_id": lt_id, + }, trigger="date", run_date=run_at, timezone=tz_utc, diff --git a/shvatka/infrastructure/scheduler/wrappers.py b/shvatka/infrastructure/scheduler/wrappers.py index 5e815194..55994ea4 100644 --- a/shvatka/infrastructure/scheduler/wrappers.py +++ b/shvatka/infrastructure/scheduler/wrappers.py @@ -58,6 +58,7 @@ async def send_hint_wrapper( level_id: int, team_id: int, hint_number: int, + lt_id: int, dao: FromDishka[HolderDao], game_view: FromDishka[GameView], scheduler: FromDishka[Scheduler], @@ -66,11 +67,15 @@ async def send_hint_wrapper( try: level = await dao.level.get_by_id(level_id) team = await dao.team.get_by_id(team_id) + game = await dao.game.get_active_game() + assert game is not None await send_hint( level=level, hint_number=hint_number, + lt_id=lt_id, team=team, + game=game, dao=dao.level_time, view=game_view, scheduler=scheduler, diff --git a/shvatka/tgbot/dialogs/game_spy/dialogs.py b/shvatka/tgbot/dialogs/game_spy/dialogs.py index 652de6d2..c6db1758 100644 --- a/shvatka/tgbot/dialogs/game_spy/dialogs.py +++ b/shvatka/tgbot/dialogs/game_spy/dialogs.py @@ -38,15 +38,17 @@ Window( Const("Актуальные сведения с полей схватки:"), Jinja( - "{% for lt in stat %}" - "{% if lt.is_finished %}" + "{% for lt in finished %}" "🏁{{ lt.team.name }} - финишировала в " "{{ lt.start_at|time_user_timezone }} ({{(now - lt.start_at) | timedelta}} назад)\n" - "{% else %}" - "🚩{{ lt.team.name }} - ур {{ lt.level_number + 1 }} начат в " + "{% endfor %}" + "{% for i, lts in stat.items() %}" + "Уровень {{ i + 1 }}\n" + "{% for lt in lts %}" + "🚩{{ lt.team.name }} - начат в " "{{ lt.start_at|time_user_timezone }} ({{(now - lt.start_at) | timedelta}} назад)\n" "Подсказка №{{lt.hint.number}} — {{lt.hint.time}} мин.\n" - "{% endif %}" + "{% endfor %}" "{% endfor %}", when=F["org"].can_spy, ), diff --git a/shvatka/tgbot/dialogs/game_spy/getters.py b/shvatka/tgbot/dialogs/game_spy/getters.py index b88cbfd5..2598f977 100644 --- a/shvatka/tgbot/dialogs/game_spy/getters.py +++ b/shvatka/tgbot/dialogs/game_spy/getters.py @@ -1,3 +1,4 @@ +from collections import defaultdict from datetime import datetime from aiogram_dialog import DialogManager @@ -26,9 +27,20 @@ async def get_org( async def get_spy( dao: HolderDao, player: dto.Player, game: dto.Game, dialog_manager: DialogManager, **_ ): - stat = await get_game_spy(game, player, dao.game_stat) + stat = sorted( + await get_game_spy(game, player, dao.game_stat), + key=lambda x: (-x.level_number, x.start_at), + ) + result = defaultdict(list) + finished = [] + for s in stat: + if s.is_finished: + finished.append(s) + else: + result[s.level_number].append(s) return { - "stat": stat, + "stat": result, + "finished": finished, "now": datetime.now(tz=tz_utc), } diff --git a/shvatka/tgbot/dialogs/level_manage/handlers.py b/shvatka/tgbot/dialogs/level_manage/handlers.py index 6a2a0158..28d60d27 100644 --- a/shvatka/tgbot/dialogs/level_manage/handlers.py +++ b/shvatka/tgbot/dialogs/level_manage/handlers.py @@ -23,6 +23,7 @@ from shvatka.tgbot.views.hint_sender import HintSender from shvatka.tgbot.views.user import render_small_card_link from .getters import get_level_and_org, get_org +from shvatka.tgbot.views.keys import render_level_keys async def edit_level(c: CallbackQuery, button: Button, manager: DialogManager): @@ -43,13 +44,7 @@ async def show_level(c: CallbackQuery, button: Button, manager: DialogManager): async def show_all_hints(author: dto.Player, hint_sender: HintSender, bot: Bot, level: dto.Level): - keys_text = "Ключи уровня:\n🔑{keys}".format(keys="\n🔑".join(level.get_keys())) - if level.get_bonus_keys(): - keys_text += "\n\nБонусные ключи:\n💰{bonus_keys}".format( - bonus_keys="\n💰".join( - [f"{key.text} ({key.bonus_minutes:+.2f}) мин." for key in level.get_bonus_keys()] - ) - ) + keys_text = f"Ключи уровня:\n{render_level_keys(level.scenario)}" await bot.send_message(author.get_chat_id(), keys_text) for hint in level.scenario.time_hints: await hint_sender.send_hints( diff --git a/shvatka/tgbot/dialogs/level_scn/handlers.py b/shvatka/tgbot/dialogs/level_scn/handlers.py index 5ad890f5..f4b03172 100644 --- a/shvatka/tgbot/dialogs/level_scn/handlers.py +++ b/shvatka/tgbot/dialogs/level_scn/handlers.py @@ -13,7 +13,6 @@ is_multiple_keys_normal, normalize_key, validate_level_id, - is_key_valid, ) from shvatka.infrastructure.db.dao.holder import HolderDao from shvatka.tgbot import states @@ -70,18 +69,11 @@ async def on_correct_keys(m: Message, dialog_: Any, manager: DialogManager, keys await manager.done({"keys": keys}) -def convert_bonus_keys(text: str) -> list[shvatka.core.models.dto.action.keys.BonusKey]: +def convert_bonus_keys(text: str) -> list[action.BonusKey]: result = [] for key_str in text.splitlines(): key, bonus = key_str.split(maxsplit=1) - if not is_key_valid(key): - raise ValueError - parsed_bonus = float(bonus) - if not (-600 < parsed_bonus < 60): - raise ValueError("bonus out of available range") - parsed_key = shvatka.core.models.dto.action.keys.BonusKey( - text=key, bonus_minutes=parsed_bonus - ) + parsed_key = action.BonusKey(text=key, bonus_minutes=float(bonus)) result.append(parsed_key) return result diff --git a/shvatka/tgbot/views/keys.py b/shvatka/tgbot/views/keys.py index ae328e17..95dc32f6 100644 --- a/shvatka/tgbot/views/keys.py +++ b/shvatka/tgbot/views/keys.py @@ -6,6 +6,7 @@ from telegraph.aio import Telegraph from shvatka.core.models import dto, enums +from shvatka.core.models.dto import scn, action from shvatka.core.services.game_stat import get_typed_keys from shvatka.core.utils.datetime_utils import tz_game, DATETIME_FORMAT from shvatka.infrastructure.db.dao.holder import HolderDao @@ -33,12 +34,12 @@ def from_key(cls, key: dto.KeyTime) -> "KeyEmoji": def render_log_keys(log_keys: dict[dto.Team, list[dto.KeyTime]]) -> str: - text = f"Лог ключей на {datetime.now(tz=tz_game).strftime(DATETIME_FORMAT)}:
" + text = f"

Лог ключей на {datetime.now(tz=tz_game).strftime(DATETIME_FORMAT)}:


" for team, keys in log_keys.items(): - text += f"
{hd.quote(team.name)}:" + text += f"

🚩{hd.quote(team.name)}:

" n_level = keys[0].level_number - 1 for i, key in enumerate(keys): - if n_level < key.level_number: + if n_level != key.level_number: # keys are sorted, so is previous and next level not equals - add caption n_level = key.level_number if i > 0: @@ -46,10 +47,10 @@ def render_log_keys(log_keys: dict[dto.Team, list[dto.KeyTime]]) -> str: text += f"Уровень №{n_level + 1}
    " text += ( f"
  1. {KeyEmoji.from_key(key).value}{hd.quote(key.text)} " - f"{key.at.astimezone(tz=tz_game).time()} " + f"{key.at.astimezone(tz=tz_game).time().isoformat()} " f"{key.player.name_mention}
  2. " ) - text += "
" + text += "


" return text @@ -81,3 +82,19 @@ async def get_or_create_keys_page( game.results.keys_url = page["url"] assert game.results.keys_url is not None return game.results.keys_url + + +def render_level_keys(level: scn.LevelScenario) -> str: + text = "" + for c in level.conditions: + if not isinstance(c, action.KeyWinCondition): + continue + text += f"🗝🗝🗝{' -> ' + c.next_level if c.next_level else ''}\n" + for k in c.keys: + text += f"🔑 {k}\n" + text += "\n" + if level.get_bonus_keys(): + text += "\nБонусные ключи:\n💰 " + "\n💰 ".join( + [f"{b.text} ({b.bonus_minutes} мин.)" for b in level.get_bonus_keys()] + ) + return text diff --git a/shvatka/tgbot/views/results/scenario.py b/shvatka/tgbot/views/results/scenario.py index 495092ec..3952aaac 100644 --- a/shvatka/tgbot/views/results/scenario.py +++ b/shvatka/tgbot/views/results/scenario.py @@ -13,7 +13,7 @@ from shvatka.core.utils.datetime_utils import DATE_FORMAT from shvatka.tgbot.config.models.bot import BotConfig from shvatka.tgbot.views.hint_sender import HintSender -from shvatka.tgbot.views.keys import render_log_keys +from shvatka.tgbot.views.keys import render_log_keys, render_level_keys from shvatka.tgbot.views.results.level_times import export_results @@ -99,16 +99,10 @@ async def publish(self): for hint_number, hint in enumerate(self.level.scenario.time_hints): if hint.time == 0: text = ( - f"🔒 Уровень № {self.level.number_in_game + 1}\n" - f"Ключи уровня:\n🔑 " + "\n🔑 ".join(self.level.scenario.get_keys()) + f"🔒 Уровень № {self.level.number_in_game + 1} " + f"({hd.quote(self.level.name_id)})\n" + f"Ключи уровня:\n{render_level_keys(self.level.scenario)}" ) - if self.level.scenario.get_bonus_keys(): - text += "\nБонусные ключи:\n💰 " + "\n💰 ".join( - [ - f"{b.text} ({b.bonus_minutes} мин.)" - for b in self.level.scenario.get_bonus_keys() - ] - ) elif hint_number == len(self.level.scenario.time_hints) - 1: text = ( f"🔖 Последняя подсказка уровня №{self.level.number_in_game + 1} " diff --git a/tests/fixtures/game_fixtures.py b/tests/fixtures/game_fixtures.py index ca82e697..5193c59d 100644 --- a/tests/fixtures/game_fixtures.py +++ b/tests/fixtures/game_fixtures.py @@ -5,13 +5,15 @@ from adaptix import Retort from shvatka.core.interfaces.clients.file_storage import FileGateway -from shvatka.core.models import dto, enums +from shvatka.core.models import dto from shvatka.core.models.dto.scn.game import RawGameScenario from shvatka.core.models.enums.played import Played -from shvatka.core.services.game import upsert_game +from shvatka.core.services.game import upsert_game, start_waivers +from shvatka.core.services.key import KeyProcessor from shvatka.core.services.player import join_team from shvatka.core.services.waiver import add_vote, approve_waivers from shvatka.core.utils.datetime_utils import tz_utc +from shvatka.core.utils.key_checker_lock import KeyCheckerFactory from shvatka.infrastructure.db.dao.holder import HolderDao @@ -33,31 +35,38 @@ async def game( @pytest_asyncio.fixture -async def started_game( +async def game_with_waivers( game: dto.FullGame, gryffindor: dto.Team, slytherin: dto.Team, + author: dto.Player, harry: dto.Player, ron: dto.Player, hermione: dto.Player, draco: dto.Player, dao: HolderDao, -) -> dto.FullGame: - await join_team(ron, gryffindor, harry, dao.team_player) - await join_team(hermione, gryffindor, harry, dao.team_player) - await dao.game.start_waivers(game) +): + return await add_waivers( + game=game, + gryffindor=gryffindor, + slytherin=slytherin, + author=author, + harry=harry, + ron=ron, + hermione=hermione, + draco=draco, + dao=dao, + ) - await add_vote(game, gryffindor, harry, Played.yes, dao.waiver_vote_adder) - await add_vote(game, gryffindor, hermione, Played.yes, dao.waiver_vote_adder) - await add_vote(game, gryffindor, ron, Played.no, dao.waiver_vote_adder) - await add_vote(game, slytherin, draco, Played.yes, dao.waiver_vote_adder) - await approve_waivers(game, gryffindor, harry, dao.waiver_approver) - await dao.game.set_started(game) - await dao.game.set_start_at(game, datetime.now(tz=tz_utc)) - await dao.level_time.set_to_level(team=gryffindor, game=game, level_number=0) - await dao.level_time.set_to_level(team=slytherin, game=game, level_number=0) - await dao.commit() - return game + +@pytest_asyncio.fixture +async def started_game( + game_with_waivers: dto.FullGame, + gryffindor: dto.Team, + slytherin: dto.Team, + dao: HolderDao, +) -> dto.FullGame: + return await set_game_started(game_with_waivers, [gryffindor, slytherin], dao) @pytest_asyncio.fixture @@ -70,88 +79,140 @@ async def finished_game( hermione: dto.Player, draco: dto.Player, dao: HolderDao, + locker: KeyCheckerFactory, ) -> dto.FullGame: game = started_game - await dao.key_time.save_key( + key_processor = KeyProcessor(dao=dao.game_player, game=game, locker=locker) + await key_processor.submit_key( key="SHWRONG", - team=gryffindor, - level=game.levels[0], - game=game, player=ron, - type_=enums.KeyType.wrong, - is_duplicate=False, + team=gryffindor, ) - await dao.key_time.save_key( + await key_processor.submit_key( key="SH123", team=gryffindor, - level=game.levels[0], - game=game, player=harry, - type_=enums.KeyType.simple, - is_duplicate=False, ) - await dao.key_time.save_key( + await key_processor.submit_key( key="SH123", team=slytherin, - level=game.levels[0], - game=game, player=draco, - type_=enums.KeyType.simple, - is_duplicate=False, ) - await dao.key_time.save_key( + await key_processor.submit_key( key="SH123", team=gryffindor, - level=game.levels[0], - game=game, player=hermione, - type_=enums.KeyType.simple, - is_duplicate=True, ) - await dao.key_time.save_key( + await key_processor.submit_key( key="SH321", team=slytherin, - level=game.levels[0], - game=game, player=draco, - type_=enums.KeyType.simple, - is_duplicate=False, ) - await dao.game_player.level_up(slytherin, game.levels[0], game) + await dao.game_player.level_up(slytherin, game.levels[0], game, 1) await asyncio.sleep(0.1) - await dao.key_time.save_key( + await key_processor.submit_key( key="SH123", team=gryffindor, - level=game.levels[0], - game=game, player=ron, - type_=enums.KeyType.simple, - is_duplicate=False, ) - await dao.game_player.level_up(gryffindor, game.levels[0], game) + await dao.game_player.level_up(gryffindor, game.levels[0], game, 1) await asyncio.sleep(0.2) - await dao.key_time.save_key( + await key_processor.submit_key( key="SHOOT", team=gryffindor, - level=game.levels[1], - game=game, player=hermione, - type_=enums.KeyType.simple, - is_duplicate=False, ) - await dao.game_player.level_up(gryffindor, game.levels[1], game) + await dao.game_player.level_up(gryffindor, game.levels[1], game, 2) await asyncio.sleep(0.1) - await dao.key_time.save_key( + await key_processor.submit_key( key="SHOOT", team=slytherin, - level=game.levels[1], - game=game, player=draco, - type_=enums.KeyType.simple, - is_duplicate=False, ) - await dao.game_player.level_up(slytherin, game.levels[1], game) + await dao.game_player.level_up(slytherin, game.levels[1], game, 2) await dao.game.set_finished(game) await dao.commit() return game + + +@pytest_asyncio.fixture +async def routed_game( + author: dto.Player, + routed_scn: RawGameScenario, + dao: HolderDao, + file_gateway: FileGateway, + retort: Retort, +): + return await upsert_game(routed_scn, author, dao.game_upserter, retort, file_gateway) + + +@pytest_asyncio.fixture +async def routed_game_with_waivers( + routed_game: dto.FullGame, + gryffindor: dto.Team, + slytherin: dto.Team, + author: dto.Player, + harry: dto.Player, + ron: dto.Player, + hermione: dto.Player, + draco: dto.Player, + dao: HolderDao, +): + return await add_waivers( + game=routed_game, + gryffindor=gryffindor, + slytherin=slytherin, + author=author, + harry=harry, + ron=ron, + hermione=hermione, + draco=draco, + dao=dao, + ) + + +@pytest_asyncio.fixture +async def started_routed_game( + routed_game_with_waivers: dto.FullGame, + gryffindor: dto.Team, + slytherin: dto.Team, + dao: HolderDao, +) -> dto.FullGame: + return await set_game_started(routed_game_with_waivers, [gryffindor, slytherin], dao) + + +async def add_waivers( + game: dto.FullGame, + gryffindor: dto.Team, + slytherin: dto.Team, + author: dto.Player, + harry: dto.Player, + ron: dto.Player, + hermione: dto.Player, + draco: dto.Player, + dao: HolderDao, +) -> dto.FullGame: + await join_team(hermione, gryffindor, harry, dao.team_player) + await join_team(ron, gryffindor, harry, dao.team_player) + + await start_waivers(game, author, dao.game) + await add_vote(game, gryffindor, harry, Played.yes, dao.waiver_vote_adder) + await add_vote(game, gryffindor, hermione, Played.yes, dao.waiver_vote_adder) + await add_vote(game, gryffindor, ron, Played.no, dao.waiver_vote_adder) + await add_vote(game, slytherin, draco, Played.yes, dao.waiver_vote_adder) + + await approve_waivers(game, gryffindor, harry, dao.waiver_approver) + await approve_waivers(game, slytherin, draco, dao.waiver_approver) + return game + + +async def set_game_started( + game: dto.FullGame, teams: list[dto.Team], dao: HolderDao +) -> dto.FullGame: + await dao.game.set_started(game) + await dao.game.set_start_at(game, datetime.now(tz=tz_utc)) + for team in teams: + await dao.level_time.set_to_level(team=team, game=game, level_number=0) + await dao.commit() + return game diff --git a/tests/fixtures/resources/routed_scn.yml b/tests/fixtures/resources/routed_scn.yml new file mode 100644 index 00000000..01a3fd63 --- /dev/null +++ b/tests/fixtures/resources/routed_scn.yml @@ -0,0 +1,88 @@ +__model_version__: 1 +name: "Routed game" +levels: + - id: first + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: + - "SH123" + - "SH321" + - type: WIN_KEY + keys: [ "SHTO3" ] + next-level: third + time-hints: + - time: 0 + hint: + - type: text + text: "загадка" + - type: text + text: "(сложная)" + - time: 1 + hint: + - type: text + text: "подсказка" + - time: 2 + hint: + - type: gps + latitude: 55.579282598950165 + longitude: 37.910306366539395 + - time: 5 + hint: + - time: text + text: "SH123\nSH321" + - id: second + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: [ "SHOOT" ] + time-hints: + - time: 0 + hint: + - type: text + text: "загадка" + - type: text + text: "(ну не очень сложная)" + - time: 1 + hint: + - type: text + text: "подсказонька" + - time: 2 + hint: + - type: text + text: "подсказка" + - type: text + text: "(простая)" + - time: 5 + hint: + - time: text + text: "SHOOT" + - id: third + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: [ "SH456" ] + - type: WIN_KEY + keys: [ "SHTO1" ] + next-level: first + time-hints: + - time: 0 + hint: + - type: text + text: "загадка" + - type: text + text: "(сложная)" + - time: 1 + hint: + - type: text + text: "подсказка" + - time: 2 + hint: + - type: gps + latitude: 55.579282598950165 + longitude: 37.910306366539395 + - time: 5 + hint: + - time: text + text: "SH456" +files: [] diff --git a/tests/fixtures/scn_fixtures.py b/tests/fixtures/scn_fixtures.py index d0ba094d..30973f86 100644 --- a/tests/fixtures/scn_fixtures.py +++ b/tests/fixtures/scn_fixtures.py @@ -44,3 +44,12 @@ def all_types_scn(fixtures_resource_path: Path) -> RawGameScenario: scn=yaml.safe_load(f), files={GUID: BytesIO(b"123"), GUID_2: BytesIO(b"890")}, ) + + +@pytest.fixture +def routed_scn(fixtures_resource_path: Path) -> RawGameScenario: + with open(fixtures_resource_path / "routed_scn.yml", encoding="utf-8") as f: + return RawGameScenario( + scn=yaml.safe_load(f), + files={}, + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index dc37c6ce..2d94839f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -34,9 +34,17 @@ from tests.fixtures.conftest import fixtures_resource_path # noqa: F401 from tests.fixtures.db_provider import TestDbProvider from tests.fixtures.file_storage import MemoryFileStorageProvider -from tests.fixtures.game_fixtures import game, finished_game, started_game # noqa: F401 +from tests.fixtures.game_fixtures import ( # noqa: F401 + game, + finished_game, + started_game, + game_with_waivers, + routed_game, + routed_game_with_waivers, + started_routed_game, +) from tests.fixtures.player import harry, hermione, ron, author, draco # noqa: F401 -from tests.fixtures.scn_fixtures import simple_scn, complex_scn, three_lvl_scn # noqa: F401 +from tests.fixtures.scn_fixtures import simple_scn, complex_scn, three_lvl_scn, routed_scn # noqa: F401 from tests.fixtures.team import gryffindor, slytherin # noqa: F401 from tests.mocks.bot import MockMessageManagerProvider, MockBotProvider from tests.mocks.datetime_mock import ClockMock @@ -117,8 +125,8 @@ async def clear_data(dao: HolderDao): await dao.organizer.delete_all() await dao.waiver.delete_all() await dao.level.delete_all() - await dao.level_time.delete_all() await dao.key_time.delete_all() + await dao.level_time.delete_all() await dao.game.delete_all() await dao.team_player.delete_all() await dao.chat.delete_all() diff --git a/tests/integration/test_game_play.py b/tests/integration/test_game_play.py index 059cfa62..b775cc2c 100644 --- a/tests/integration/test_game_play.py +++ b/tests/integration/test_game_play.py @@ -1,3 +1,4 @@ +from contextlib import suppress from datetime import datetime, timedelta import pytest @@ -6,14 +7,11 @@ from shvatka.core.games.interactors import GamePlayReaderInteractor from shvatka.core.models import dto, enums from shvatka.core.models.enums import GameStatus -from shvatka.core.models.enums.played import Played -from shvatka.core.services.game import start_waivers from shvatka.core.services.game_play import start_game, send_hint, check_key from shvatka.core.services.game_stat import get_typed_keys from shvatka.core.services.key import KeyProcessor from shvatka.core.services.organizers import get_orgs -from shvatka.core.services.player import join_team -from shvatka.core.services.waiver import add_vote, approve_waivers +from shvatka.core.services.player import join_team, leave from shvatka.core.utils import exceptions from shvatka.core.utils.datetime_utils import tz_utc from shvatka.core.utils.key_checker_lock import KeyCheckerFactory @@ -31,6 +29,85 @@ from tests.utils.time_key import assert_time_key +@pytest.mark.asyncio +async def test_start_game( + game_with_waivers: dto.FullGame, + gryffindor: dto.Team, + slytherin: dto.Team, + author: dto.Player, + harry: dto.Player, + ron: dto.Player, + hermione: dto.Player, + draco: dto.Player, + dao: HolderDao, + check_dao: HolderDao, + scheduler: SchedulerMock, +): + dummy_view = GameViewMock() + dummy_log = GameLogWriterMock() + game_with_waivers.start_at = datetime.now(tz=tz_utc) + + await start_game(game_with_waivers, dao.game_starter, dummy_log, dummy_view, scheduler) + + dummy_log.assert_one_event( + GameLogEvent(GameLogType.GAME_STARTED, {"game": game_with_waivers.name}) + ) + scheduler.assert_only_one_hint_for_team(game_with_waivers.levels[0], gryffindor, 1) + scheduler.assert_only_one_hint_for_team(game_with_waivers.levels[0], slytherin, 1) + scheduler.assert_no_unchecked() + dummy_view.assert_send_only_puzzle_for_team(gryffindor, game_with_waivers.levels[0]) + dummy_view.assert_send_only_puzzle_for_team(slytherin, game_with_waivers.levels[0]) + dummy_view.assert_no_unchecked() + assert 2 == await check_dao.level_time.count() + + +@pytest.mark.asyncio +async def test_wrong_key( + author: dto.Player, + harry: dto.Player, + gryffindor: dto.Team, + started_game: dto.FullGame, + dao: HolderDao, + check_dao: HolderDao, + locker: KeyCheckerFactory, + scheduler: SchedulerMock, +): + game = started_game + dummy_view = GameViewMock() + dummy_log = GameLogWriterMock() + + dummy_org_notifier = OrgNotifierMock() + key_processor = KeyProcessor(dao.game_player, game, locker) + await check_key( + key="SHWRONG", + player=harry, + team=gryffindor, + game=game, + dao=dao.game_player, + view=dummy_view, + game_log=dummy_log, + org_notifier=dummy_org_notifier, + key_processor=key_processor, + locker=locker, + scheduler=scheduler, + ) + + keys = await get_typed_keys(game=game, player=author, dao=check_dao.typed_keys) + assert [gryffindor] == list(keys.keys()) + assert 1 == len(keys[gryffindor]) + expected_first_key = dto.KeyTime( + text="SHWRONG", + type_=enums.KeyType.wrong, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=0, + player=harry, + team=gryffindor, + ) + assert_time_key(expected_first_key, list(keys[gryffindor])[0]) + dummy_view.assert_wrong_key_only(expected_first_key) + + @pytest.mark.asyncio async def test_game_play( dao: HolderDao, @@ -39,30 +116,24 @@ async def test_game_play( scheduler: SchedulerMock, author: dto.Player, harry: dto.Player, + draco: dto.Player, hermione: dto.Player, gryffindor: dto.Team, - game: dto.FullGame, + started_game: dto.FullGame, ): - await start_waivers(game, author, dao.game) - - await join_team(hermione, gryffindor, harry, dao.team_player) - await add_vote(game, gryffindor, harry, Played.yes, dao.waiver_vote_adder) - await add_vote(game, gryffindor, hermione, Played.yes, dao.waiver_vote_adder) - await approve_waivers(game, gryffindor, harry, dao.waiver_approver) + game = started_game + # delete slytherin from game + await leave(draco, draco, dao.team_leaver) dummy_view = GameViewMock() dummy_log = GameLogWriterMock() - game.start_at = datetime.now(tz=tz_utc) - await start_game(game, dao.game_starter, dummy_log, dummy_view, scheduler) - dummy_log.assert_one_event(GameLogEvent(GameLogType.GAME_STARTED, {"game": game.name})) - scheduler.assert_one_planned_hint(game.levels[0], gryffindor, 1) - dummy_view.assert_send_only_puzzle(gryffindor, game.levels[0]) - assert 1 == await check_dao.level_time.count() await send_hint( level=game.levels[0], hint_number=1, + lt_id=(await dao.level_time.get_current_level_time(gryffindor, game)).id, team=gryffindor, + game=game, dao=dao.level_time, view=dummy_view, scheduler=scheduler, @@ -85,25 +156,9 @@ async def test_game_play( "locker": locker, "scheduler": scheduler, } - await check_key(key="SHWRONG", **key_kwargs) - keys = await get_typed_keys(game=game, player=author, dao=check_dao.typed_keys) - - assert [gryffindor] == list(keys.keys()) - assert 1 == len(keys[gryffindor]) - expected_first_key = dto.KeyTime( - text="SHWRONG", - type_=enums.KeyType.wrong, - is_duplicate=False, - at=datetime.now(tz=tz_utc), - level_number=0, - player=harry, - team=gryffindor, - ) - assert_time_key(expected_first_key, list(keys[gryffindor])[0]) - dummy_view.assert_wrong_key_only(expected_first_key) await check_key(key="SH123", **key_kwargs) - expected_second_key = dto.KeyTime( + expected_first_key = dto.KeyTime( text="SH123", type_=enums.KeyType.simple, is_duplicate=False, @@ -112,10 +167,10 @@ async def test_game_play( player=harry, team=gryffindor, ) - dummy_view.assert_correct_key_only(expected_second_key) + dummy_view.assert_correct_key_only(expected_first_key) await check_key(key="SH123", **key_kwargs) - expected_third_key = dto.KeyTime( + expected_second_key = dto.KeyTime( text="SH123", type_=enums.KeyType.simple, is_duplicate=True, @@ -124,10 +179,10 @@ async def test_game_play( player=harry, team=gryffindor, ) - dummy_view.assert_duplicate_key_only(expected_third_key) + dummy_view.assert_duplicate_key_only(expected_second_key) await check_key(key="SH321", **key_kwargs) - expected_fourth_key = dto.KeyTime( + expected_third_key = dto.KeyTime( text="SH321", type_=enums.KeyType.simple, is_duplicate=False, @@ -139,12 +194,12 @@ async def test_game_play( dummy_org_notifier.assert_one_event( LevelUp(team=gryffindor, new_level=game.levels[1], orgs_list=orgs) ) - dummy_view.assert_correct_key_only(expected_fourth_key) + dummy_view.assert_correct_key_only(expected_third_key) dummy_view.assert_send_only_puzzle(gryffindor, game.levels[1]) await check_key(key="SHOOT", **key_kwargs) dummy_log.assert_one_event(GameLogEvent(GameLogType.GAME_FINISHED, {"game": game.name})) - expected_fifth_key = dto.KeyTime( + expected_fourth_key = dto.KeyTime( text="SHOOT", type_=enums.KeyType.simple, is_duplicate=False, @@ -153,19 +208,204 @@ async def test_game_play( player=harry, team=gryffindor, ) - dummy_view.assert_correct_key_only(expected_fifth_key) + dummy_view.assert_correct_key_only(expected_fourth_key) dummy_view.assert_game_finished_only(gryffindor) dummy_view.assert_game_finished_all({gryffindor}) keys = await get_typed_keys(game=game, player=author, dao=check_dao.typed_keys) assert [gryffindor] == list(keys.keys()) - assert 5 == len(keys[gryffindor]) + assert 4 == len(keys[gryffindor]) assert_time_key(expected_first_key, list(keys[gryffindor])[0]) assert_time_key(expected_second_key, list(keys[gryffindor])[1]) assert_time_key(expected_third_key, list(keys[gryffindor])[2]) assert_time_key(expected_fourth_key, list(keys[gryffindor])[3]) - assert_time_key(expected_fifth_key, list(keys[gryffindor])[4]) + assert await dao.game_player.is_all_team_finished(game) + assert GameStatus.finished == (await dao.game.get_by_id(game.id, author)).status + dummy_view.assert_no_unchecked() + dummy_org_notifier.assert_no_calls() + + +@pytest.mark.asyncio +async def test_fast_play_routed_game( + dao: HolderDao, + locker: KeyCheckerFactory, + check_dao: HolderDao, + scheduler: SchedulerMock, + author: dto.Player, + harry: dto.Player, + draco: dto.Player, + hermione: dto.Player, + gryffindor: dto.Team, + started_routed_game: dto.FullGame, +): + game = started_routed_game + # delete slytherin from game + await leave(draco, draco, dao.team_leaver) + dummy_view = GameViewMock() + dummy_log = GameLogWriterMock() + + dummy_org_notifier = OrgNotifierMock() + orgs = await get_orgs(game, dao.organizer) + key_processor = KeyProcessor(dao.game_player, game, locker) + key_kwargs = { + "player": harry, + "team": gryffindor, + "game": game, + "dao": dao.game_player, + "view": dummy_view, + "game_log": dummy_log, + "org_notifier": dummy_org_notifier, + "key_processor": key_processor, + "locker": locker, + "scheduler": scheduler, + } + await check_key(key="SHTO3", **key_kwargs) + expected_first_key = dto.KeyTime( + text="SHTO3", + type_=enums.KeyType.simple, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=0, + player=harry, + team=gryffindor, + ) + dummy_view.assert_correct_key_only(expected_first_key) + dummy_org_notifier.assert_one_event( + LevelUp(team=gryffindor, new_level=game.levels[2], orgs_list=orgs) + ) + dummy_view.assert_send_only_puzzle(gryffindor, game.levels[2]) + + await check_key(key="SH456", **key_kwargs) + expected_second_key = dto.KeyTime( + text="SH456", + type_=enums.KeyType.simple, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=2, + player=harry, + team=gryffindor, + ) + dummy_view.assert_correct_key_only(expected_second_key) + dummy_view.assert_game_finished_only(gryffindor) + dummy_view.assert_game_finished_all({gryffindor}) + + keys = await get_typed_keys(game=game, player=author, dao=check_dao.typed_keys) + + assert list(keys.keys()) == [gryffindor] + assert len(keys[gryffindor]) == 2 + assert_time_key(expected_first_key, list(keys[gryffindor])[0]) + assert_time_key(expected_second_key, list(keys[gryffindor])[1]) + assert await dao.game_player.is_all_team_finished(game) + assert GameStatus.finished == (await dao.game.get_by_id(game.id, author)).status + dummy_view.assert_no_unchecked() + dummy_org_notifier.assert_no_calls() + + +@pytest.mark.asyncio +async def test_cycle_play_routed_game( + dao: HolderDao, + locker: KeyCheckerFactory, + check_dao: HolderDao, + scheduler: SchedulerMock, + author: dto.Player, + harry: dto.Player, + draco: dto.Player, + hermione: dto.Player, + gryffindor: dto.Team, + started_routed_game: dto.FullGame, +): + game = started_routed_game + # delete slytherin from game + await leave(draco, draco, dao.team_leaver) + dummy_view = GameViewMock() + dummy_log = GameLogWriterMock() + + dummy_org_notifier = OrgNotifierMock() + orgs = await get_orgs(game, dao.organizer) + key_processor = KeyProcessor(dao.game_player, game, locker) + key_kwargs = { + "player": harry, + "team": gryffindor, + "game": game, + "dao": dao.game_player, + "view": dummy_view, + "game_log": dummy_log, + "org_notifier": dummy_org_notifier, + "key_processor": key_processor, + "locker": locker, + "scheduler": scheduler, + } + await check_key(key="SHTO3", **key_kwargs) + expected_first_key = dto.KeyTime( + text="SHTO3", + type_=enums.KeyType.simple, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=0, + player=harry, + team=gryffindor, + ) + dummy_view.assert_correct_key_only(expected_first_key) + dummy_org_notifier.assert_one_event( + LevelUp(team=gryffindor, new_level=game.levels[2], orgs_list=orgs) + ) + dummy_view.assert_send_only_puzzle(gryffindor, game.levels[2]) + + await check_key(key="SHTO1", **key_kwargs) + expected_second_key = dto.KeyTime( + text="SHTO1", + type_=enums.KeyType.simple, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=2, + player=harry, + team=gryffindor, + ) + dummy_view.assert_correct_key_only(expected_second_key) + dummy_org_notifier.assert_one_event( + LevelUp(team=gryffindor, new_level=game.levels[0], orgs_list=orgs) + ) + dummy_view.assert_send_only_puzzle(gryffindor, game.levels[0]) + + await check_key(key="SHTO3", **key_kwargs) + expected_third_key = dto.KeyTime( + text="SHTO3", + type_=enums.KeyType.simple, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=0, + player=harry, + team=gryffindor, + ) + dummy_view.assert_correct_key_only(expected_third_key) + dummy_org_notifier.assert_one_event( + LevelUp(team=gryffindor, new_level=game.levels[2], orgs_list=orgs) + ) + dummy_view.assert_send_only_puzzle(gryffindor, game.levels[2]) + + await check_key(key="SH456", **key_kwargs) + expected_last_key = dto.KeyTime( + text="SH456", + type_=enums.KeyType.simple, + is_duplicate=False, + at=datetime.now(tz=tz_utc), + level_number=2, + player=harry, + team=gryffindor, + ) + dummy_view.assert_correct_key_only(expected_last_key) + dummy_view.assert_game_finished_only(gryffindor) + dummy_view.assert_game_finished_all({gryffindor}) + + keys = await get_typed_keys(game=game, player=author, dao=check_dao.typed_keys) + + assert list(keys.keys()) == [gryffindor] + assert len(keys[gryffindor]) == 4 + assert_time_key(expected_first_key, list(keys[gryffindor])[0]) + assert_time_key(expected_second_key, list(keys[gryffindor])[1]) + assert_time_key(expected_third_key, list(keys[gryffindor])[2]) + assert_time_key(expected_last_key, list(keys[gryffindor])[3]) assert await dao.game_player.is_all_team_finished(game) assert GameStatus.finished == (await dao.game.get_by_id(game.id, author)).status dummy_view.assert_no_unchecked() @@ -174,7 +414,7 @@ async def test_game_play( @pytest.mark.asyncio async def test_get_current_hints( - game: dto.FullGame, + game_with_waivers: dto.FullGame, dishka_request: AsyncContainer, dao: HolderDao, author: dto.Player, @@ -183,14 +423,9 @@ async def test_get_current_hints( hermione: dto.Player, gryffindor: dto.Team, ): - await start_waivers(game, author, dao.game) - - await join_team(hermione, gryffindor, harry, dao.team_player) - await add_vote(game, gryffindor, harry, Played.yes, dao.waiver_vote_adder) - await add_vote(game, gryffindor, hermione, Played.yes, dao.waiver_vote_adder) - await approve_waivers(game, gryffindor, harry, dao.waiver_approver) + await leave(ron, ron, dao.team_leaver) level_time = models.LevelTime( - game_id=game.id, + game_id=game_with_waivers.id, team_id=gryffindor.id, level_number=0, start_at=datetime.now(tz=tz_utc) - timedelta(minutes=2), @@ -205,6 +440,7 @@ async def test_get_current_hints( assert hints_harry.hints == hints.hints with pytest.raises(exceptions.PlayerNotInTeam): await interactor(ron._user) - await join_team(ron, gryffindor, harry, dao.team_player) + with suppress(exceptions.PlayerRestoredInTeam): + await join_team(ron, gryffindor, harry, dao.team_player) with pytest.raises(exceptions.WaiverError): await interactor(ron._user) diff --git a/tests/integration/test_game_stat.py b/tests/integration/test_game_stat.py index 3c8902f3..ebc38c3c 100644 --- a/tests/integration/test_game_stat.py +++ b/tests/integration/test_game_stat.py @@ -1,6 +1,5 @@ -import operator from datetime import timedelta, datetime -from itertools import starmap, pairwise +from itertools import pairwise import pytest @@ -16,7 +15,9 @@ async def test_game_level_times(finished_game: dto.FullGame, dao: HolderDao): game_stat = await get_game_stat(finished_game, finished_game.author, dao.game_stat) for team, level_times in game_stat.level_times.items(): assert all(team.id == lt.team.id for lt in level_times) - assert all(starmap(operator.lt, pairwise(lt.level_number for lt in level_times))) + assert len(level_times) > 0 + for prev, curr in pairwise(level_times): + assert prev.level_number <= curr.level_number @pytest.mark.asyncio diff --git a/tests/mocks/game_view.py b/tests/mocks/game_view.py index 0f6eb777..8a93b423 100644 --- a/tests/mocks/game_view.py +++ b/tests/mocks/game_view.py @@ -46,6 +46,16 @@ def assert_send_only_puzzle(self, team: dto.Team, level: dto.Level) -> None: assert sent[0] == team assert sent[1] == level + def assert_send_only_puzzle_for_team(self, team: dto.Team, level: dto.Level) -> None: + for i, (t, _) in enumerate(self.send_puzzle_calls): + if t == team: + sent = self.send_puzzle_calls.pop(i) + break + else: + raise AssertionError(f"No puzzle for team {team}") + assert sent[0] == team + assert sent[1] == level + def assert_send_only_hint(self, team: dto.Team, hint_number: int, level: dto.Level) -> None: sent = self.send_hint_calls.pop() assert len(self.send_hint_calls) == 0 diff --git a/tests/mocks/scheduler_mock.py b/tests/mocks/scheduler_mock.py index 6bf4ded7..001b7a05 100644 --- a/tests/mocks/scheduler_mock.py +++ b/tests/mocks/scheduler_mock.py @@ -18,15 +18,34 @@ class SchedulerMock(Scheduler): def __init__(self) -> None: self.plain_prepare_calls: list[dto.Game] = [] self.plain_start_calls: list[dto.Game] = [] - self.plain_hint_calls: list[tuple[dto.Level, dto.Team, int, datetime]] = [] + self.plain_hint_calls: list[tuple[dto.Level, dto.Team, int, int, datetime]] = [] self.cancel_scheduled_game_calls: list[dto.Game] = [] def assert_one_planned_hint(self, level: dto.Level, team: dto.Team, hint_number: int) -> None: assert len(self.plain_hint_calls) == 1 actual = self.plain_hint_calls.pop() - assert (level, team, hint_number) == actual[:-1] + assert (level, team, hint_number) == actual[:-2] assert isinstance(actual[-1], datetime) + def assert_only_one_hint_for_team( + self, level: dto.Level, team: dto.Team, hint_number: int + ) -> None: + assert len(self.plain_hint_calls) >= 1 + for i, (_, t, *_) in enumerate(self.plain_hint_calls): + if t == team: + actual = self.plain_hint_calls.pop(i) + break + else: + raise AssertionError(f"No hint for team {team}") + assert (level, team, hint_number) == actual[:-2] + assert isinstance(actual[-1], datetime) + + def assert_no_unchecked(self): + assert len(self.plain_hint_calls) == 0 + assert len(self.plain_start_calls) == 0 + assert len(self.plain_prepare_calls) == 0 + assert len(self.cancel_scheduled_game_calls) == 0 + async def plain_prepare(self, game: dto.Game) -> None: self.plain_prepare_calls.append(game) @@ -34,9 +53,9 @@ async def plain_start(self, game: dto.Game) -> None: self.plain_start_calls.append(game) async def plain_hint( - self, level: dto.Level, team: dto.Team, hint_number: int, run_at: datetime + self, level: dto.Level, team: dto.Team, hint_number: int, lt_id: int, run_at: datetime ) -> None: - self.plain_hint_calls.append((level, team, hint_number, run_at)) + self.plain_hint_calls.append((level, team, hint_number, lt_id, run_at)) async def cancel_scheduled_game(self, game: dto.Game) -> None: self.cancel_scheduled_game_calls.append(game) diff --git a/tests/unit/serialization/test_deserialize.py b/tests/unit/serialization/test_deserialize.py index a3f552a0..7121b952 100644 --- a/tests/unit/serialization/test_deserialize.py +++ b/tests/unit/serialization/test_deserialize.py @@ -115,6 +115,7 @@ def test_serialize_simple(retort: Retort): "conditions": [ { "type": "WIN_KEY", + "next-level": None, "keys": ("SH1",), } ], @@ -188,6 +189,7 @@ def test_serialize_simple(retort: Retort): "conditions": [ { "type": "WIN_KEY", + "next-level": None, "keys": ("SH2",), } ], @@ -261,6 +263,7 @@ def test_serialize_simple(retort: Retort): "conditions": [ { "type": "WIN_KEY", + "next-level": None, "keys": ("SH3",), } ], @@ -334,6 +337,7 @@ def test_serialize_simple(retort: Retort): "conditions": [ { "type": "WIN_KEY", + "next-level": None, "keys": ("SH4",), } ],