diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d96bf906..37a2dd81 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,7 @@ on: jobs: lint-and-test: uses: ./.github/workflows/test.yml + secrets: inherit build: needs: lint-and-test runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c6c21ab0..8699ef48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,6 +63,7 @@ jobs: run: source .venv/bin/activate && pytest --cov-report "xml:coverage.xml" --cov=shvatka tests/ - name: Coverage comment id: coverageComment + continue-on-error: true uses: MishaKav/pytest-coverage-comment@main with: pytest-xml-coverage-path: ./coverage.xml @@ -74,20 +75,16 @@ jobs: hide-comment: false report-only-changed-files: false remove-link-from-badge: false - coverage-badge: - needs: [test] - runs-on: ubuntu-latest - continue-on-error: true - steps: - name: Create the Badge uses: schneegans/dynamic-badges-action@v1.7.0 + continue-on-error: true with: - auth: ${{ secrets.BAGE_GIST }} // don't passed from direct push + auth: ${{ secrets.BAGE_GIST }} gistID: 99469cb5f8a18784c1f03d229a799427 filename: bage.json label: Coverage Report - message: ${{ steps.coverageComment.outputs.coverage }} // can't access from another job - color: ${{ steps.coverageComment.outputs.color }} // can't access from another job + message: ${{ steps.coverageComment.outputs.coverage }} + color: ${{ steps.coverageComment.outputs.color }} namedLogo: python docs: needs: [build] diff --git a/pyproject.toml b/pyproject.toml index fe6be332..5f99b637 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ omit = [ ".*", "*/site-packages/*", "shvatka/tgbot/*", + "shvatka/infrastructure/db/migrations/*", "*/__main__.py", ] @@ -97,6 +98,7 @@ exclude_lines = [ "if __name__ == .__main__.:", "class .*\bProtocol\\):", "@(abc\\.)?abstractmethod", + "(typing\\.)?assert_never", ] [tool.black] diff --git a/shvatka/api/models/responses.py b/shvatka/api/models/responses.py index 6fe97975..d1c7d92b 100644 --- a/shvatka/api/models/responses.py +++ b/shvatka/api/models/responses.py @@ -3,15 +3,26 @@ from datetime import datetime from typing import Sequence, Generic -from adaptix import Retort, dumper +from adaptix import Retort, dumper, P +from shvatka.common.factory import REQUIRED_GAME_RECIPES from shvatka.core.games.dto import CurrentHints from shvatka.core.models import dto, enums -from shvatka.core.models.dto import scn +from shvatka.core.models.dto import scn, action from shvatka.core.models.enums import GameStatus T = typing.TypeVar("T") -retort = Retort(recipe=[dumper(scn.HintsList, lambda x: x.hints)]) +retort = Retort( + recipe=[ + *REQUIRED_GAME_RECIPES, + dumper(scn.HintsList, lambda x: x.hints), + # TODO https://github.com/reagento/adaptix/issues/348 + dumper( + P[action.KeyBonusCondition].keys, + lambda keys: [{"text": x.text, "bonus_minutes": x.bonus_minutes} for x in keys], + ), + ] +) @dataclass diff --git a/shvatka/common/data_examples.py b/shvatka/common/data_examples.py index c0463f17..db60a0c3 100644 --- a/shvatka/common/data_examples.py +++ b/shvatka/common/data_examples.py @@ -39,7 +39,7 @@ name_id="level_100", game_id=10, number_in_game=0, - scenario=scn.LevelScenario( + scenario=scn.LevelScenario.legacy_factory( id="level_100", keys={"SH1"}, time_hints=scn.HintsList( @@ -102,7 +102,7 @@ name_id="level_101", game_id=10, number_in_game=1, - scenario=scn.LevelScenario( + scenario=scn.LevelScenario.legacy_factory( id="level_101", keys={"SH2"}, time_hints=scn.HintsList( @@ -165,7 +165,7 @@ name_id="level_102", game_id=10, number_in_game=0, - scenario=scn.LevelScenario( + scenario=scn.LevelScenario.legacy_factory( id="level_102", keys={"SH3"}, time_hints=scn.HintsList( @@ -228,7 +228,7 @@ name_id="level_103", game_id=10, number_in_game=0, - scenario=scn.LevelScenario( + scenario=scn.LevelScenario.legacy_factory( id="level_103", keys={"SH4"}, time_hints=scn.HintsList( diff --git a/shvatka/common/factory.py b/shvatka/common/factory.py index 04b87f9b..f79ddccc 100644 --- a/shvatka/common/factory.py +++ b/shvatka/common/factory.py @@ -18,8 +18,9 @@ from telegraph.aio import Telegraph from shvatka.common.url_factory import UrlFactory -from shvatka.core.models.dto import scn -from shvatka.core.models.dto.scn import HintsList, TimeHint +from shvatka.core.models.dto import scn, action +from shvatka.core.models.dto.action import AnyCondition +from shvatka.core.models.dto.scn import HintsList, TimeHint, Conditions from shvatka.core.models.schems import schemas from shvatka.core.utils import exceptions from shvatka.core.utils.input_validation import validate_level_id, is_multiple_keys_normal @@ -37,9 +38,14 @@ def create_telegraph(self, bot_config: BotConfig) -> Telegraph: REQUIRED_GAME_RECIPES = [ + name_mapping(map={"__model_version__": "__model_version__"}), loader(HintsList, lambda x: HintsList.parse(x), Chain.LAST), - ABCProxy(HintsList, list[TimeHint]), # internal class, can be broken in next version adaptix - dumper(set, lambda x: tuple(x)), + ABCProxy(HintsList, list[TimeHint]), # internal class, can be broken in next adaptix version + loader(Conditions, lambda x: Conditions(x), Chain.LAST), + ABCProxy( + Conditions, list[AnyCondition] + ), # internal class, can be broken in next adaptix version + dumper(P[action.KeyWinCondition].keys, list), ] @@ -62,6 +68,13 @@ def create_retort(self) -> Retort: name_style=adaptix.NameStyle.LOWER_KEBAB, ), *REQUIRED_GAME_RECIPES, + # TODO https://github.com/reagento/adaptix/issues/348 + dumper( + P[action.KeyBonusCondition].keys, + lambda keys: [ + {"text": x.text, "bonus-minutes": x.bonus_minutes} for x in keys + ], + ), validator( pred=P[scn.LevelScenario].id, func=lambda x: validate_level_id(x) is not None, diff --git a/shvatka/common/log_utils.py b/shvatka/common/log_utils.py new file mode 100644 index 00000000..1f9a5d95 --- /dev/null +++ b/shvatka/common/log_utils.py @@ -0,0 +1,6 @@ +from base64 import b64encode +from typing import Any + + +def obfuscate_sensitive(information: Any) -> str: + return b64encode(str(information).encode("utf8")).decode("utf8") diff --git a/shvatka/core/interfaces/dal/game_play.py b/shvatka/core/interfaces/dal/game_play.py index 4b4948fa..69ed93a4 100644 --- a/shvatka/core/interfaces/dal/game_play.py +++ b/shvatka/core/interfaces/dal/game_play.py @@ -54,6 +54,11 @@ async def save_key( ) -> dto.KeyTime: raise NotImplementedError + async def get_team_typed_keys( + self, game: dto.Game, team: dto.Team, level_number: int + ) -> list[dto.KeyTime]: + raise NotImplementedError + async def level_up(self, team: dto.Team, level: dto.Level, game: dto.Game) -> None: raise NotImplementedError diff --git a/shvatka/core/migration_utils/__init__.py b/shvatka/core/migration_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shvatka/core/migration_utils/from_1_to_2/__init__.py b/shvatka/core/migration_utils/from_1_to_2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shvatka/core/migration_utils/from_1_to_2/migrators.py b/shvatka/core/migration_utils/from_1_to_2/migrators.py new file mode 100644 index 00000000..06cbfb45 --- /dev/null +++ b/shvatka/core/migration_utils/from_1_to_2/migrators.py @@ -0,0 +1,79 @@ +from shvatka.core.migration_utils import models_0 +from shvatka.core.models.dto import scn, action + + +def bonus_key_0_to_1(bonus_key: models_0.BonusKey) -> action.BonusKey: + return action.BonusKey(text=bonus_key.text, bonus_minutes=bonus_key.bonus_minutes) + + +def hint_0_to_1(hint: models_0.AnyHint) -> scn.AnyHint: + match hint: + case models_0.TextHint(text=text): + return scn.TextHint(text=text) + case models_0.GPSHint(latitude=latitude, longitude=longitude): + return scn.GPSHint(latitude=latitude, longitude=longitude) + case models_0.VenueHint as vh: + return scn.VenueHint( + latitude=vh.latitude, + longitude=vh.longitude, + title=vh.title, + address=vh.address, + foursquare_id=vh.foursquare_id, + foursquare_type=vh.foursquare_type, + ) + case models_0.PhotoHint(file_guid=file_guid, caption=caption): + return scn.PhotoHint(file_guid=file_guid, caption=caption) + case models_0.AudioHint(file_guid=file_guid, caption=caption, thumb_guid=thumb_guid): + return scn.AudioHint(file_guid=file_guid, thumb_guid=thumb_guid, caption=caption) + case models_0.VideoHint(file_guid=file_guid, caption=caption, thumb_guid=thumb_guid): + return scn.VideoHint(file_guid=file_guid, thumb_guid=thumb_guid, caption=caption) + case models_0.DocumentHint(file_guid=file_guid, caption=caption, thumb_guid=thumb_guid): + return scn.DocumentHint(file_guid=file_guid, caption=caption, thumb_guid=thumb_guid) + case models_0.AnimationHint(file_guid=file_guid, caption=caption, thumb_guid=thumb_guid): + return scn.AnimationHint(file_guid=file_guid, caption=caption, thumb_guid=thumb_guid) + case models_0.VoiceHint(file_guid=file_guid, caption=caption): + return scn.VoiceHint(file_guid=file_guid, caption=caption) + case models_0.VideoNoteHint(file_guid=guid): + return scn.VideoNoteHint(file_guid=guid) + case models_0.ContactHint as ch: + return scn.ContactHint( + phone_number=ch.phone_number, + first_name=ch.first_name, + last_name=ch.last_name, + vcard=ch.vcard, + ) + case models_0.StickerHint(file_guid=guid): + return scn.StickerHint(file_guid=guid) + case _: + raise RuntimeError("unknown hint type") + + +def time_hint_0_to_1(time_hint: models_0.TimeHint) -> scn.TimeHint: + return scn.TimeHint( + time=time_hint.time, + hint=[hint_0_to_1(hint) for hint in time_hint.hint], + ) + + +def hints_0_to_1(hints: models_0.HintsList) -> scn.HintsList: + return scn.HintsList([time_hint_0_to_1(hint) for hint in hints]) + + +def level_0_to_1(level: models_0.LevelScenario) -> scn.LevelScenario: + conditions: list[action.AnyCondition] = [action.KeyWinCondition(set(level.keys))] + if level.bonus_keys: + conditions.append( + action.KeyBonusCondition({bonus_key_0_to_1(b) for b in level.bonus_keys}) + ) + return scn.LevelScenario( + id=level.id, + time_hints=hints_0_to_1(level.time_hints), + conditions=scn.Conditions(conditions), + __model_version__=1, + ) + + +def game_0_to_1(game: models_0.GameScenario) -> scn.GameScenario: + return scn.GameScenario( + name=game.name, levels=[level_0_to_1(lvl) for lvl in game.levels], __model_version__=1 + ) diff --git a/shvatka/core/migration_utils/models_0/__init__.py b/shvatka/core/migration_utils/models_0/__init__.py new file mode 100644 index 00000000..99e09c33 --- /dev/null +++ b/shvatka/core/migration_utils/models_0/__init__.py @@ -0,0 +1,39 @@ +from .file_content import ( + FileMeta, + FileContentLink, + TgLink, + ParsedTgLink, + SavedFileMeta, + StoredFileMeta, + FileMetaLightweight, + UploadedFileMeta, + VerifiableFileMeta, +) +from .game import ( + GameScenario, + FullGameScenario, + ParsedGameScenario, + ParsedCompletedGameScenario, + RawGameScenario, + UploadedGameScenario, +) +from .hint_part import ( + AnyHint, + BaseHint, + FileMixin, + TextHint, + GPSHint, + VenueHint, + AudioHint, + VideoHint, + DocumentHint, + AnimationHint, + VoiceHint, + VideoNoteHint, + StickerHint, + PhotoHint, + ContactHint, +) +from .level import LevelScenario, SHKey, BonusKey, HintsList +from .parsed_zip import ParsedZip +from .time_hint import TimeHint diff --git a/shvatka/core/migration_utils/models_0/file_content.py b/shvatka/core/migration_utils/models_0/file_content.py new file mode 100644 index 00000000..b12ac05b --- /dev/null +++ b/shvatka/core/migration_utils/models_0/file_content.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from shvatka.core.models import dto +from shvatka.core.models.enums.hint_type import HintType + + +@dataclass +class TgLink: + file_id: str + """telegram file_id""" + content_type: HintType + """type of content""" + + +@dataclass +class FileContentLink: + file_path: str + """path to file in file system""" + + +@dataclass +class FileMetaLightweight: + guid: str + """GUID for filename in file storage, DB and in archive""" + original_filename: str + """Filename from user before renamed to guid""" + extension: str + """extension with leading dot: ".zip" ".tar.gz" etc""" + content_type: HintType | None = field(kw_only=True, default=None) + """type of content""" + + @property + def local_file_name(self): + return self.guid + (self.extension or "") + + @property + def public_filename(self): + return self.original_filename + (self.extension or "") + + +@dataclass +class StoredFileMeta(FileMetaLightweight): + file_content_link: FileContentLink + + +@dataclass +class UploadedFileMeta(FileMetaLightweight): + tg_link: TgLink | None = None + + +@dataclass +class FileMeta(StoredFileMeta): + tg_link: TgLink + + +@dataclass +class VerifiableFileMeta(FileMeta): + author_id: int + + +@dataclass +class SavedFileMeta(VerifiableFileMeta): + id: int + author: dto.Player + + +@dataclass +class ParsedTgLink(TgLink): + filename: str | None = None diff --git a/shvatka/core/migration_utils/models_0/game.py b/shvatka/core/migration_utils/models_0/game.py new file mode 100644 index 00000000..df189c38 --- /dev/null +++ b/shvatka/core/migration_utils/models_0/game.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import BinaryIO, Sequence, Literal + +from shvatka.core.models import enums +from shvatka.core.models.dto.export_stat import GameStat +from . import UploadedFileMeta, FileMetaLightweight +from .file_content import FileMeta +from .level import LevelScenario + + +@dataclass +class GameScenario: + name: str + levels: list[LevelScenario] + __model_version__: Literal[0] + + +@dataclass +class FullGameScenario(GameScenario): + files: Sequence[FileMeta] + + +@dataclass +class UploadedGameScenario(GameScenario): + files: list[UploadedFileMeta] + + +@dataclass +class ParsedGameScenario(GameScenario): + files: list[FileMetaLightweight] + + +@dataclass +class ParsedCompletedGameScenario(ParsedGameScenario): + id: int + start_at: datetime + files_contents: dict[str, BinaryIO] + stat: GameStat + status: enums.GameStatus = enums.GameStatus.complete + + +@dataclass +class RawGameScenario: + scn: dict + files: dict[str, BinaryIO] + stat: dict | None = None diff --git a/shvatka/core/migration_utils/models_0/hint_part.py b/shvatka/core/migration_utils/models_0/hint_part.py new file mode 100644 index 00000000..f3681cf4 --- /dev/null +++ b/shvatka/core/migration_utils/models_0/hint_part.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import typing +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Literal + +from shvatka.core.models.enums.hint_type import HintType + + +class BaseHint(ABC): + type: str + + @abstractmethod + def get_guids(self) -> list[str]: + raise NotImplementedError + + +@dataclass +class TextHint(BaseHint): + text: str + type: Literal["text"] = HintType.text.name + + def get_guids(self) -> list[str]: + return [] + + +@dataclass +class LocationMixin: + latitude: float + longitude: float + + +@dataclass +class GPSHint(BaseHint, LocationMixin): + type: Literal["gps"] = HintType.gps.name + + def get_guids(self) -> list[str]: + return [] + + +@dataclass +class VenueHint(BaseHint, LocationMixin): + title: str + address: str + foursquare_id: str | None = None + foursquare_type: str | None = None + type: Literal["venue"] = HintType.venue.name + + def get_guids(self) -> list[str]: + return [] + + +@dataclass +class CaptionMixin: + caption: str | None = None + + +@dataclass +class FileMixin: + file_guid: str + + +@dataclass +class PhotoHint(BaseHint, CaptionMixin, FileMixin): + type: Literal["photo"] = HintType.photo.name + + def get_guids(self) -> list[str]: + return [self.file_guid] + + +@dataclass +class ThumbMixin: + thumb_guid: str | None = None + + def get_thumb_guid(self) -> list[str]: + return [self.thumb_guid] if self.thumb_guid else [] + + +@dataclass +class AudioHint(BaseHint, CaptionMixin, ThumbMixin, FileMixin): + type: Literal["audio"] = HintType.audio.name + + def get_guids(self) -> list[str]: + result = [self.file_guid] + result.extend(self.get_thumb_guid()) + return result + + +@dataclass +class VideoHint(BaseHint, CaptionMixin, ThumbMixin, FileMixin): + type: Literal["video"] = HintType.video.name + + def get_guids(self) -> list[str]: + result = [self.file_guid] + result.extend(self.get_thumb_guid()) + return result + + +@dataclass +class DocumentHint(BaseHint, CaptionMixin, ThumbMixin, FileMixin): + type: Literal["document"] = HintType.document.name + + def get_guids(self) -> list[str]: + result = [self.file_guid] + result.extend(self.get_thumb_guid()) + return result + + +@dataclass +class AnimationHint(BaseHint, CaptionMixin, ThumbMixin, FileMixin): + type: Literal["animation"] = HintType.animation.name + + def get_guids(self) -> list[str]: + result = [self.file_guid] + result.extend(self.get_thumb_guid()) + return result + + +@dataclass +class VoiceHint(BaseHint, CaptionMixin, FileMixin): + type: Literal["voice"] = HintType.voice.name + + def get_guids(self) -> list[str]: + return [self.file_guid] + + +@dataclass +class VideoNoteHint(BaseHint, FileMixin): + type: Literal["video_note"] = HintType.video_note.name + + def get_guids(self) -> list[str]: + return [self.file_guid] + + +@dataclass +class ContactHint(BaseHint): + phone_number: str + first_name: str + last_name: str | None = None + vcard: str | None = None + type: Literal["contact"] = HintType.contact.name + + def get_guids(self) -> list[str]: + return [] + + +@dataclass +class StickerHint(BaseHint, FileMixin): + type: Literal["sticker"] = HintType.sticker.name + + def get_guids(self) -> list[str]: + return [] + + +AnyHint: typing.TypeAlias = ( + TextHint + | GPSHint + | VenueHint + | ContactHint + | PhotoHint + | AudioHint + | VideoHint + | DocumentHint + | AnimationHint + | VoiceHint + | VideoNoteHint + | StickerHint +) diff --git a/shvatka/core/migration_utils/models_0/level.py b/shvatka/core/migration_utils/models_0/level.py new file mode 100644 index 00000000..8dd0afb8 --- /dev/null +++ b/shvatka/core/migration_utils/models_0/level.py @@ -0,0 +1,70 @@ +import typing +from collections.abc import Sequence, Iterable +from dataclasses import dataclass, field + + +from shvatka.core.utils import exceptions +from .hint_part import AnyHint +from .time_hint import TimeHint + +SHKey: typing.TypeAlias = str + + +@dataclass(frozen=True) +class BonusKey: + text: str + bonus_minutes: float + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BonusKey): + return NotImplemented + return self.text == other.text + + def __hash__(self) -> int: + return hash(self.text) + + +class HintsList(Sequence[TimeHint]): + def __init__(self, hints: list[TimeHint]): + self.verify(hints) + self.hints = hints + + @classmethod + def parse(cls, hints: list[TimeHint]): + return cls(cls.normalize(hints)) + + @staticmethod + def normalize(hints: list[TimeHint]) -> list[TimeHint]: + hint_map: dict[int, list[AnyHint]] = {} + for hint in hints: + if not hint.hint: + continue + hint_map.setdefault(hint.time, []).extend(hint.hint) + return [TimeHint(k, v) for k, v in sorted(hint_map.items(), key=lambda x: x[0])] + + @staticmethod + def verify(hints: Iterable[TimeHint]) -> None: + current_time = -1 + times: set[int] = set() + for hint in hints: + if hint.time in times: + raise exceptions.LevelError( + text=f"Contains multiple times hints for time {hint.time}" + ) + if hint.time <= current_time: + raise exceptions.LevelError(text="hints are not sorted") + current_time = hint.time + times.add(hint.time) + if not hint.hint: + raise exceptions.LevelError(text=f"There is no hint for time {hint.time}") + if 0 not in times: + raise exceptions.LevelError(text="There is no hint for 0 min") + + +@dataclass +class LevelScenario: + id: str + time_hints: HintsList + keys: set[SHKey] = field(default_factory=set) + bonus_keys: set[BonusKey] = field(default_factory=set) + __model_version__: typing.Literal[0] = 0 diff --git a/shvatka/core/migration_utils/models_0/parsed_zip.py b/shvatka/core/migration_utils/models_0/parsed_zip.py new file mode 100644 index 00000000..eaab3a06 --- /dev/null +++ b/shvatka/core/migration_utils/models_0/parsed_zip.py @@ -0,0 +1,42 @@ +import typing +from contextlib import contextmanager +from dataclasses import dataclass +from typing import ContextManager, BinaryIO, TextIO +from zipfile import Path + +import yaml + +from .game import RawGameScenario + + +@dataclass +class ParsedZip: + scn: Path + files: dict[str, Path] + results: Path | None = None + + @contextmanager # type: ignore[arg-type] + def open(self) -> ContextManager[RawGameScenario]: # type: ignore[misc] + contents: dict[str, BinaryIO] = {} + results = None + try: + for guid, path in self.files.items(): + contents[guid] = typing.cast(BinaryIO, path.open("rb")) + results = self.open_results_if_present() + with self.scn.open("r", encoding="utf8") as scn_f: + yield RawGameScenario( + scn=yaml.safe_load(scn_f), + files=contents, + stat=yaml.safe_load(results) if results else None, + ) + finally: + for file in contents.values(): + file.close() + if results: + results.close() + + def open_results_if_present(self) -> TextIO | None: + if self.results: + return typing.cast(TextIO, self.results.open("r", encoding="utf8")) + else: + return None diff --git a/shvatka/core/migration_utils/models_0/time_hint.py b/shvatka/core/migration_utils/models_0/time_hint.py new file mode 100644 index 00000000..6f4566ae --- /dev/null +++ b/shvatka/core/migration_utils/models_0/time_hint.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from .hint_part import AnyHint + + +@dataclass +class TimeHint: + time: int + hint: list[AnyHint] + + +@dataclass +class EnumeratedTimeHint(TimeHint): + number: int diff --git a/shvatka/core/models/dto/action/__init__.py b/shvatka/core/models/dto/action/__init__.py new file mode 100644 index 00000000..5da040b9 --- /dev/null +++ b/shvatka/core/models/dto/action/__init__.py @@ -0,0 +1,18 @@ +import typing + +from .interface import Condition, Action, State, Decision, DecisionType, StateHolder +from .decisions import NotImplementedActionDecision, Decisions +from .keys import ( + SHKey, + BonusKey, + KeyDecision, + KeyWinCondition, + TypedKeyAction, + TypedKeysState, + BonusKeyDecision, + KeyBonusCondition, + WrongKeyDecision, +) +from .state_holder import InMemoryStateHolder + +AnyCondition: typing.TypeAlias = KeyWinCondition | KeyBonusCondition diff --git a/shvatka/core/models/dto/action/decisions.py b/shvatka/core/models/dto/action/decisions.py new file mode 100644 index 00000000..d09bfbc9 --- /dev/null +++ b/shvatka/core/models/dto/action/decisions.py @@ -0,0 +1,59 @@ +import logging +from dataclasses import dataclass +from typing import Literal, Sequence, overload + +from shvatka.common.log_utils import obfuscate_sensitive +from shvatka.core.models.dto.action.interface import DecisionType, Decision + +logger = logging.getLogger(__name__) + + +@dataclass +class NotImplementedActionDecision(Decision): + type: Literal[DecisionType.NOT_IMPLEMENTED] = DecisionType.NOT_IMPLEMENTED + + +class Decisions(Sequence[Decision]): + def __init__(self, decisions: list[Decision]): + self.decisions = decisions + + @overload + def __getitem__(self, index: int) -> Decision: + return self.decisions[index] + + @overload + def __getitem__(self, index: slice) -> Sequence[Decision]: + return self.decisions[index] + + def __getitem__(self, index): + return self.decisions[index] + + def __len__(self): + return len(self.decisions) + + def __iter__(self): + return iter(self.decisions) + + def get_significant(self) -> "Decisions": + return self.get_all_except(DecisionType.NOT_IMPLEMENTED, DecisionType.NO_ACTION) + + def get_implemented(self) -> "Decisions": + return self.get_all_except(DecisionType.NOT_IMPLEMENTED) + + def get_exactly_one(self, level_id: str = "unknown") -> Decision: + if len(self.decisions) != 1: + logger.warning( + "in level %s there is more than one duplicate correct key decision %s", + level_id, + obfuscate_sensitive(self.decisions), + ) + return self.decisions[0] + + def get_all(self, *type_: type) -> "Decisions": + return Decisions([d for d in self.decisions if isinstance(d, type_)]) + + def get_all_except(self, *type_: DecisionType) -> "Decisions": + return Decisions([d for d in self.decisions if d.type not in type_]) + + def get_all_only(self, *type_: DecisionType) -> "Decisions": + return Decisions([d for d in self.decisions if d.type in type_]) diff --git a/shvatka/core/models/dto/action/interface.py b/shvatka/core/models/dto/action/interface.py new file mode 100644 index 00000000..20cf00e9 --- /dev/null +++ b/shvatka/core/models/dto/action/interface.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import enum +import typing +from typing import Protocol + + +class ConditionType(enum.StrEnum): + WIN_KEY = enum.auto() + BONUS_KEY = enum.auto() + + +class Condition(Protocol): + type: str + + def check(self, action: Action, state_holder: StateHolder) -> Decision: + raise NotImplementedError + + +class Action(Protocol): + pass + + +class State(Protocol): + pass + + +T = typing.TypeVar("T", bound=State) + + +class StateHolder(Protocol): + 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() diff --git a/shvatka/core/models/dto/action/keys.py b/shvatka/core/models/dto/action/keys.py new file mode 100644 index 00000000..a0d1f68f --- /dev/null +++ b/shvatka/core/models/dto/action/keys.py @@ -0,0 +1,136 @@ +import typing +from dataclasses import dataclass +from typing import Literal + +from shvatka.core.models import enums +from . import StateHolder +from .decisions import NotImplementedActionDecision +from .interface import Action, State, Decision, Condition, DecisionType, ConditionType + +SHKey: typing.TypeAlias = str + + +@dataclass(frozen=True) +class BonusKey: + text: SHKey + bonus_minutes: float + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BonusKey): + return NotImplemented + return self.text == other.text + + def __hash__(self) -> int: + return hash(self.text) + + +@dataclass +class TypedKeyAction(Action): + key: SHKey + + +@dataclass +class TypedKeysState(State): + typed_correct: set[SHKey] + all_typed: set[SHKey] + + def is_duplicate(self, action: TypedKeyAction) -> bool: + return action.key in self.all_typed + + +@dataclass +class WrongKeyDecision(Decision): + duplicate: bool + key: str + type: Literal[DecisionType.NO_ACTION] = DecisionType.NO_ACTION + key_type: typing.Literal[enums.KeyType.wrong] = enums.KeyType.wrong + + @property + def key_text(self) -> str: + return self.key + + +@dataclass +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 +class KeyWinCondition(Condition): + keys: set[SHKey] + type: Literal["WIN_KEY"] = ConditionType.WIN_KEY.name + + def check(self, action: Action, state_holder: StateHolder) -> Decision: + if not isinstance(action, TypedKeyAction): + return NotImplementedActionDecision() + state = state_holder.get(TypedKeysState) + type_: DecisionType + if not self._is_correct(action): + 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 + 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, + duplicate=state.is_duplicate(action), + key=action.key, + ) + + def _is_correct(self, action: TypedKeyAction) -> bool: + return action.key in self.keys + + def _is_all_typed(self, action: TypedKeyAction, state: TypedKeysState) -> bool: + return self.keys == {*state.typed_correct, action.key} + + +@dataclass +class BonusKeyDecision(Decision): + type: DecisionType + key_type: enums.KeyType + duplicate: bool + key: BonusKey + + @property + def key_text(self) -> str: + return self.key.text + + +@dataclass +class KeyBonusCondition(Condition): + keys: set[BonusKey] + type: Literal["BONUS_KEY"] = ConditionType.BONUS_KEY.name + + def check(self, action: Action, state_holder: StateHolder) -> Decision: + if not isinstance(action, TypedKeyAction): + return NotImplementedActionDecision() + state = state_holder.get(TypedKeysState) + bonus = self._get_bonus(action) + if bonus is None: + return WrongKeyDecision(duplicate=state.is_duplicate(action), key=action.key) + return BonusKeyDecision( + type=DecisionType.BONUS_TIME, + key_type=enums.KeyType.bonus, + duplicate=state.is_duplicate(action), + key=bonus, + ) + + def _get_bonus(self, action: TypedKeyAction) -> BonusKey | None: + for bonus_key in self.keys: + if action.key == bonus_key.text: + return bonus_key + return None diff --git a/shvatka/core/models/dto/action/state_holder.py b/shvatka/core/models/dto/action/state_holder.py new file mode 100644 index 00000000..f2362993 --- /dev/null +++ b/shvatka/core/models/dto/action/state_holder.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from . import TypedKeysState, SHKey +from .interface import StateHolder, T + + +@dataclass +class InMemoryStateHolder(StateHolder): + typed_correct: set[SHKey] + all_typed: set[SHKey] + + def get(self, state_class: type[T]) -> T: + if state_class == TypedKeysState: + return TypedKeysState( # type: ignore[return-value] + typed_correct=self.typed_correct, + all_typed=self.all_typed, + ) + else: + raise NotImplementedError(f"unknown state type {type(state_class)}") diff --git a/shvatka/core/models/dto/level.py b/shvatka/core/models/dto/level.py index 09578601..741483df 100644 --- a/shvatka/core/models/dto/level.py +++ b/shvatka/core/models/dto/level.py @@ -4,7 +4,8 @@ from datetime import timedelta from .player import Player -from .scn.level import LevelScenario, BonusKey +from .scn.level import LevelScenario +from .action.keys import BonusKey from .scn.time_hint import TimeHint, EnumeratedTimeHint diff --git a/shvatka/core/models/dto/scn/__init__.py b/shvatka/core/models/dto/scn/__init__.py index bc3bd059..ec20c356 100644 --- a/shvatka/core/models/dto/scn/__init__.py +++ b/shvatka/core/models/dto/scn/__init__.py @@ -18,13 +18,22 @@ UploadedGameScenario, ) from .hint_part import ( + AnyHint, BaseHint, FileMixin, TextHint, GPSHint, + VenueHint, + AudioHint, + VideoHint, + DocumentHint, + AnimationHint, + VoiceHint, + VideoNoteHint, + StickerHint, PhotoHint, ContactHint, ) -from .level import LevelScenario, SHKey, BonusKey, HintsList +from .level import LevelScenario, HintsList, Conditions from .parsed_zip import ParsedZip from .time_hint import TimeHint diff --git a/shvatka/core/models/dto/scn/game.py b/shvatka/core/models/dto/scn/game.py index 45b45d51..a015c84b 100644 --- a/shvatka/core/models/dto/scn/game.py +++ b/shvatka/core/models/dto/scn/game.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import BinaryIO, Sequence +from typing import BinaryIO, Sequence, Literal from shvatka.core.models import enums from shvatka.core.models.dto.export_stat import GameStat @@ -13,6 +13,7 @@ class GameScenario: name: str levels: list[LevelScenario] + __model_version__: Literal[1] @dataclass diff --git a/shvatka/core/models/dto/scn/level.py b/shvatka/core/models/dto/scn/level.py index c2cea195..57bb8382 100644 --- a/shvatka/core/models/dto/scn/level.py +++ b/shvatka/core/models/dto/scn/level.py @@ -1,29 +1,33 @@ -import typing +import logging from collections.abc import Sequence, Iterable -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import timedelta -from typing import overload - +from typing import overload, Literal from shvatka.core.utils import exceptions from .hint_part import AnyHint from .time_hint import TimeHint, EnumeratedTimeHint - -SHKey: typing.TypeAlias = str - - -@dataclass(frozen=True) -class BonusKey: - text: str - bonus_minutes: float - - def __eq__(self, other: object) -> bool: - if not isinstance(other, BonusKey): - return NotImplemented - return self.text == other.text - - def __hash__(self) -> int: - return hash(self.text) +from shvatka.core.models.dto.action import ( + Action, + Decision, + StateHolder, + DecisionType, + Decisions, + KeyDecision, + KeyBonusCondition, + NotImplementedActionDecision, + BonusKeyDecision, + AnyCondition, +) +from shvatka.core.models.dto.action.keys import ( + SHKey, + KeyWinCondition, + TypedKeyAction, + WrongKeyDecision, + BonusKey, +) + +logger = logging.getLogger(__name__) class HintsList(Sequence[TimeHint]): @@ -129,12 +133,79 @@ def __repr__(self): return repr(self.hints) +class Conditions(Sequence[AnyCondition]): + def __init__(self, conditions: Sequence[AnyCondition]): + self.validate(conditions) + self.conditions: Sequence[AnyCondition] = conditions + + @staticmethod + def validate(conditions: Sequence[AnyCondition]) -> None: + keys: set[str] = set() + win_conditions = [] + for c in conditions: + if isinstance(c, KeyWinCondition): + win_conditions.append(c) + if keys.intersection(c.keys): + raise exceptions.LevelError( + text=f"keys already exists {keys.intersection(c.keys)}" + ) + keys = keys.union(c.keys) + elif isinstance(c, KeyBonusCondition): + if keys.intersection({k.text for k in c.keys}): + raise exceptions.LevelError( + text=f"keys already exists {keys.intersection(c.keys)}" + ) + keys = keys.union({k.text for k in c.keys}) + if not win_conditions: + raise exceptions.LevelError(text="There is no win condition") + + def get_keys(self) -> set[str]: + result: set[SHKey] = set() + for condition in self.conditions: + if isinstance(condition, KeyWinCondition): + result = result.union(condition.keys) + return result + + def get_bonus_keys(self) -> set[BonusKey]: + result: set[BonusKey] = set() + for condition in self.conditions: + if isinstance(condition, KeyBonusCondition): + result = result.union(condition.keys) + return result + + @overload + def __getitem__(self, index: int) -> AnyCondition: + return self.conditions[index] + + @overload + def __getitem__(self, index: slice) -> Sequence[AnyCondition]: + return self.conditions[index] + + def __getitem__(self, index): + return self.conditions[index] + + def __len__(self): + return len(self.conditions) + + def __repr__(self): + return repr(self.conditions) + + def __eq__(self, other): + if not isinstance(other, Conditions): + return NotImplemented + return self.conditions == other.conditions + + @dataclass class LevelScenario: id: str time_hints: HintsList - keys: set[SHKey] = field(default_factory=set) - bonus_keys: set[BonusKey] = field(default_factory=set) + conditions: Conditions + __model_version__: Literal[1] + + def __post_init__(self): + if not self.conditions: + raise exceptions.LevelError(text="no win conditions are present") def get_hint(self, hint_number: int) -> TimeHint: return self.time_hints[hint_number] @@ -145,11 +216,32 @@ def get_hint_by_time(self, time: timedelta) -> EnumeratedTimeHint: def is_last_hint(self, hint_number: int) -> bool: return len(self.time_hints) == hint_number + 1 - def get_keys(self) -> set[str]: - return self.keys + def check(self, action: Action, state: StateHolder) -> Decision: + decisions = Decisions([cond.check(action, state) for cond in self.conditions]) + implemented = decisions.get_implemented() + if not implemented: + return NotImplementedActionDecision() + if isinstance(action, TypedKeyAction): + if bonuses := implemented.get_all(BonusKeyDecision): + return bonuses.get_exactly_one(self.id) + key_decisions = implemented.get_all(KeyDecision, WrongKeyDecision) + if not key_decisions: + return NotImplementedActionDecision() + if not key_decisions.get_significant(): + assert all(d.type == DecisionType.NO_ACTION for d in key_decisions) + if duplicate_correct := key_decisions.get_all(KeyDecision): + return duplicate_correct.get_exactly_one(self.id) + return key_decisions[0] + significant_key_decisions = key_decisions.get_significant() + return significant_key_decisions.get_exactly_one(self.id) + else: + return NotImplementedActionDecision() + + def get_keys(self) -> set[SHKey]: + return self.conditions.get_keys() def get_bonus_keys(self) -> set[BonusKey]: - return self.bonus_keys + return self.conditions.get_bonus_keys() def get_guids(self) -> list[str]: guids = [] @@ -163,3 +255,21 @@ def hints_count(self) -> int: def get_hints_for_timedelta(self, delta: timedelta) -> list[TimeHint]: return self.time_hints.get_hints_for_timedelta(delta) + + @classmethod + def legacy_factory( + cls, + id: str, # noqa: A002 + time_hints: HintsList, + keys: set[SHKey], + bonus_keys: set[BonusKey] | None = None, + ) -> "LevelScenario": + conditions: list[AnyCondition] = [KeyWinCondition(keys)] + if bonus_keys: + conditions.append(KeyBonusCondition(bonus_keys)) + return cls( + id=id, + time_hints=time_hints, + conditions=Conditions(conditions), + __model_version__=1, + ) diff --git a/shvatka/core/models/dto/time_key.py b/shvatka/core/models/dto/time_key.py index 814dd293..e7197a52 100644 --- a/shvatka/core/models/dto/time_key.py +++ b/shvatka/core/models/dto/time_key.py @@ -4,12 +4,12 @@ from datetime import datetime from shvatka.core.models import dto, enums -from . import scn +from . import action @dataclass(frozen=True) class KeyTime: - text: scn.SHKey + text: action.SHKey type_: enums.KeyType is_duplicate: bool at: datetime diff --git a/shvatka/core/services/game.py b/shvatka/core/services/game.py index 3c7eb7a9..96b61d2d 100644 --- a/shvatka/core/services/game.py +++ b/shvatka/core/services/game.py @@ -129,7 +129,10 @@ async def get_game_package( file_metas = await get_file_metas(game, author, dao) contents = await get_file_contents(file_metas, file_gateway) scenario = scn.FullGameScenario( - name=game.name, levels=[level.scenario for level in game.levels], files=file_metas + name=game.name, + levels=[level.scenario for level in game.levels], + files=file_metas, + __model_version__=1, ) if game.is_complete(): assert game.start_at diff --git a/shvatka/core/services/game_play.py b/shvatka/core/services/game_play.py index 50acd56f..7566e4a5 100644 --- a/shvatka/core/services/game_play.py +++ b/shvatka/core/services/game_play.py @@ -125,6 +125,8 @@ async def check_key( ) new_key = await key_processor.check_key(key=key, player=player, team=team) + if new_key is None: + return if new_key.is_duplicate: await view.duplicate_key(key=new_key) return diff --git a/shvatka/core/services/key.py b/shvatka/core/services/key.py index 6666135e..33c9d6cc 100644 --- a/shvatka/core/services/key.py +++ b/shvatka/core/services/key.py @@ -1,20 +1,27 @@ +import logging from dataclasses import dataclass + from shvatka.core.interfaces.dal.game_play import GamePlayerDao from shvatka.core.models import dto, enums -from shvatka.core.models.dto import scn +from shvatka.core.models.dto import action from shvatka.core.utils import exceptions from shvatka.core.utils.input_validation import is_key_valid from shvatka.core.utils.key_checker_lock import KeyCheckerFactory +logger = logging.getLogger(__name__) + + @dataclass class KeyProcessor: dao: GamePlayerDao game: dto.FullGame locker: KeyCheckerFactory - async def check_key(self, key: str, team: dto.Team, player: dto.Player) -> dto.InsertedKey: + async def check_key( + self, key: str, team: dto.Team, player: dto.Player + ) -> dto.InsertedKey | None: if not is_key_valid(key): raise exceptions.InvalidKey(key=key, team=team, player=player, game=self.game) return await self.submit_key(key=key, player=player, team=team) @@ -24,50 +31,69 @@ async def submit_key( key: str, player: dto.Player, team: dto.Team, - ) -> dto.InsertedKey: - is_level_up = False + ) -> dto.InsertedKey | None: async with self.locker(team): level = await self.dao.get_current_level(team, self.game) - parsed_key = await self.parse_key(key, level) - saved_key = await self.dao.save_key( - key=parsed_key.text, - team=team, - level=level, - game=self.game, - player=player, - type_=parsed_key.type_, - is_duplicate=await self.is_duplicate(level=level, team=team, key=key), - ) - typed_keys = await self.dao.get_correct_typed_keys( + assert level.number_in_game is not None + correct_keys = await self.dao.get_correct_typed_keys( level=level, game=self.game, team=team ) - if parsed_key.type_ == enums.KeyType.simple: - # add just now added key to typed, because no flush in dao - typed_keys.add(parsed_key.text) - if is_level_up := await self.is_level_up(typed_keys, level): + all_typed = await self.dao.get_team_typed_keys( + self.game, team, level_number=level.number_in_game + ) + state = action.InMemoryStateHolder( + typed_correct=correct_keys, + all_typed={k.text for k in all_typed}, + ) + decision = level.scenario.check( + action=action.TypedKeyAction(key=key), + state=state, + ) + if isinstance( + decision, action.KeyDecision | action.BonusKeyDecision | action.WrongKeyDecision + ): + saved_key = await self.dao.save_key( + key=decision.key_text, + team=team, + level=level, + 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) - await self.dao.commit() - return dto.InsertedKey.from_key_time(saved_key, is_level_up, parsed_key=parsed_key) + await self.dao.commit() + return dto.InsertedKey.from_key_time( + saved_key, is_level_up, parsed_key=decision_to_parsed_key(decision) + ) + elif isinstance(decision, action.NotImplementedActionDecision): + logger.warning("impossible decision here cant be not implemented") + return None + else: + logger.warning("impossible decision here is %s", type(decision)) + return None - async def get_bonus_value(self, key: str, level: dto.Level) -> float: - for bonus_key in level.get_bonus_keys(): - if bonus_key.text == key: - return bonus_key.bonus_minutes - raise AssertionError - async def parse_key(self, key: str, level: dto.Level) -> dto.ParsedKey: - if key in level.get_bonus_keys_texts(): +def decision_to_parsed_key( + decision: action.KeyDecision | action.BonusKeyDecision | action.WrongKeyDecision, +) -> dto.ParsedKey: + match decision: + case action.KeyDecision(): + return dto.ParsedKey( + type_=decision.key_type, + text=decision.key_text, + ) + case action.BonusKeyDecision(): return dto.ParsedBonusKey( type_=enums.KeyType.bonus, - text=key, - bonus_minutes=await self.get_bonus_value(key, level), + text=decision.key_text, + bonus_minutes=decision.key.bonus_minutes, ) - if key in level.get_keys(): - return dto.ParsedKey(type_=enums.KeyType.simple, text=key) - return dto.ParsedKey(type_=enums.KeyType.wrong, text=key) - - async def is_duplicate(self, key: scn.SHKey, level: dto.Level, team: dto.Team) -> bool: - return await self.dao.is_key_duplicate(level, team, key) - - async def is_level_up(self, typed_keys: set[scn.SHKey], level: dto.Level) -> bool: - return typed_keys == level.get_keys() + case action.WrongKeyDecision(): + return dto.ParsedKey( + type_=decision.key_type, + text=decision.key, + ) + case _: + raise NotImplementedError(f"unknown decision type {type(decision)}") diff --git a/shvatka/infrastructure/crawler/game_scn/parser/parser.py b/shvatka/infrastructure/crawler/game_scn/parser/parser.py index c2ab0016..58773c12 100644 --- a/shvatka/infrastructure/crawler/game_scn/parser/parser.py +++ b/shvatka/infrastructure/crawler/game_scn/parser/parser.py @@ -263,9 +263,9 @@ def build_time_hint(self): def build_level(self): self.build_time_hint() - level = scn.LevelScenario( + level = scn.LevelScenario.legacy_factory( id=f"game_{self.id}-lvl_{self.level_number}", - time_hints=self.time_hints, + time_hints=scn.HintsList(self.time_hints), keys=self.keys, ) self.levels.append(level) @@ -292,6 +292,7 @@ async def build(self) -> scn.ParsedCompletedGameScenario: start_at=self.start_at, team_identity=export_stat.TeamIdentity.forum_name, ), + __model_version__=1, ) return game diff --git a/shvatka/infrastructure/crawler/game_scn/parser/parser_svast_engine.py b/shvatka/infrastructure/crawler/game_scn/parser/parser_svast_engine.py index 0bdac5ff..dfaadacf 100644 --- a/shvatka/infrastructure/crawler/game_scn/parser/parser_svast_engine.py +++ b/shvatka/infrastructure/crawler/game_scn/parser/parser_svast_engine.py @@ -229,9 +229,9 @@ def build_time_hint(self): def build_level(self): self.build_time_hint() - level = scn.LevelScenario( + level = scn.LevelScenario.legacy_factory( id=f"game_{self.id}-lvl_{self.level_number}", - time_hints=self.time_hints, + time_hints=scn.HintsList(self.time_hints), keys={typing.cast(scn.TextHint, self.time_hints[-1].hint).text}, ) self.levels.append(level) @@ -257,6 +257,7 @@ async def build(self) -> scn.ParsedCompletedGameScenario: id=self.id, start_at=self.start_at, ), + __model_version__=1, ) return game diff --git a/shvatka/infrastructure/crawler/game_scn/uploader/game_mapper.py b/shvatka/infrastructure/crawler/game_scn/uploader/game_mapper.py index ba405b82..4b25f627 100644 --- a/shvatka/infrastructure/crawler/game_scn/uploader/game_mapper.py +++ b/shvatka/infrastructure/crawler/game_scn/uploader/game_mapper.py @@ -20,7 +20,7 @@ def map_level_for_upload(level: dto.Level) -> data.LevelForUpload: hint_number=0, next_hint_time=scn.time_hints[1].time, text=hint_parts_to_text(first_hint.hint), - key="".join(scn.keys), + key="".join(scn.get_keys()), brain_key="", ) hints = [] diff --git a/shvatka/infrastructure/db/dao/complex/game_play.py b/shvatka/infrastructure/db/dao/complex/game_play.py index 6c28e64f..c451b12f 100644 --- a/shvatka/infrastructure/db/dao/complex/game_play.py +++ b/shvatka/infrastructure/db/dao/complex/game_play.py @@ -120,6 +120,11 @@ async def save_key( is_duplicate=is_duplicate, ) + async def get_team_typed_keys( + self, game: dto.Game, team: dto.Team, level_number: int + ) -> list[dto.KeyTime]: + return await self.key_time.get_team_typed_keys(game, team, level_number) + 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( diff --git a/shvatka/infrastructure/db/migrations/versions/20241201-144338_74618499d318_updated_scenarios.py b/shvatka/infrastructure/db/migrations/versions/20241201-144338_74618499d318_updated_scenarios.py new file mode 100644 index 00000000..ad63212a --- /dev/null +++ b/shvatka/infrastructure/db/migrations/versions/20241201-144338_74618499d318_updated_scenarios.py @@ -0,0 +1,92 @@ +"""updated scenarios + +Revision ID: 74618499d318 +Revises: 84b3c1dab323 +Create Date: 2024-12-01 14:43:38.379748 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "74618499d318" +down_revision = "84b3c1dab323" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + WITH scn AS ( + SELECT jsonb_insert( + l.scenario::JSONB, + '{conditions}', + jsonb_path_query_array( + jsonb_build_array( + jsonb_build_object( + 'type', 'WIN_KEY', + 'keys', l.scenario::JSONB->'keys' + ), + jsonb_build_object( + 'type', 'BONUS_KEY', + 'keys', jsonb_extract_path(l.scenario::JSONB, 'bonus_keys') + ) + ), + '$[*] ? (@.keys != null && @.keys.size() > 0)' + ) + ) - 'keys' - 'bonus_keys' AS scenario, + l.id + FROM levels AS l + ) + UPDATE levels lvl + SET scenario = scn.scenario + FROM scn + WHERE scn.id = lvl.id + """ + ) + + +def downgrade(): + op.execute( + """ + WITH scn AS ( + SELECT jsonb_insert( + jsonb_insert( + l.scenario::JSONB, + '{keys}', + (SELECT COALESCE(jsonb_agg(k), '[]') AS flattened_keys + FROM ( + SELECT jsonb_array_elements(elem->'keys') AS k + FROM ( + SELECT jsonb_array_elements( + jsonb_path_query_array( + l.scenario::JSONB, + '$.conditions[*] ? (@.type == "WIN_KEY")' + ) + ) AS elem + ) sub_query + ) keys) + ), + '{bonus_keys}', + (SELECT COALESCE(jsonb_agg(k), '[]') AS flattened_keys + FROM ( + SELECT jsonb_array_elements(elem->'keys') AS k + FROM ( + SELECT jsonb_array_elements( + jsonb_path_query_array( + l.scenario::JSONB, + '$.conditions[*] ? (@.type == "BONUS_KEY")' + ) + ) AS elem + ) sub_query + ) keys) + ) - 'conditions' AS scenario, + l.id + FROM levels AS l + ) + UPDATE levels lvl + SET scenario = scn.scenario + FROM scn + WHERE scn.id = lvl.id + """ + ) diff --git a/shvatka/infrastructure/db/migrations/versions/20241201-221732_1659768228ec_added_model_version.py b/shvatka/infrastructure/db/migrations/versions/20241201-221732_1659768228ec_added_model_version.py new file mode 100644 index 00000000..8938e901 --- /dev/null +++ b/shvatka/infrastructure/db/migrations/versions/20241201-221732_1659768228ec_added_model_version.py @@ -0,0 +1,51 @@ +"""added model-version + +Revision ID: 1659768228ec +Revises: 74618499d318 +Create Date: 2024-12-01 22:17:32.958776 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "1659768228ec" +down_revision = "74618499d318" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + WITH scn AS ( + SELECT jsonb_insert( + l.scenario::JSONB, + '{__model_version__}', + '1' + ) AS scenario, + l.id + FROM levels AS l + ) + UPDATE levels lvl + SET scenario = scn.scenario + FROM scn + WHERE scn.id = lvl.id + """ + ) + + +def downgrade(): + op.execute( + """ + WITH scn AS ( + SELECT + l.scenario::JSONB - '__model_version__' AS scenario, + l.id + FROM levels AS l + ) + UPDATE levels lvl + SET scenario = scn.scenario + FROM scn + WHERE scn.id = lvl.id + """ + ) diff --git a/shvatka/infrastructure/db/models/level.py b/shvatka/infrastructure/db/models/level.py index 5a03af4c..f0d68b86 100644 --- a/shvatka/infrastructure/db/models/level.py +++ b/shvatka/infrastructure/db/models/level.py @@ -1,26 +1,36 @@ +import logging import typing from typing import Any -from adaptix import Retort +from adaptix import Retort, dumper, P from sqlalchemy import Integer, Text, ForeignKey, JSON, TypeDecorator, UniqueConstraint from sqlalchemy.engine import Dialect from sqlalchemy.orm import relationship, mapped_column, Mapped from shvatka.common.factory import REQUIRED_GAME_RECIPES from shvatka.core.models import dto -from shvatka.core.models.dto import scn +from shvatka.core.models.dto import scn, action from shvatka.infrastructure.db.models import Base if typing.TYPE_CHECKING: from .game import Game from .player import Player +logger = logging.getLogger(__name__) + class ScenarioField(TypeDecorator): impl = JSON cache_ok = True retort = Retort( - recipe=REQUIRED_GAME_RECIPES, + recipe=[ + # TODO https://github.com/reagento/adaptix/issues/348 + dumper( + P[action.KeyBonusCondition].keys, + lambda keys: [{"text": x.text, "bonus_minutes": x.bonus_minutes} for x in keys], + ), + *REQUIRED_GAME_RECIPES, + ], ) def coerce_compared_value(self, op: Any, value: Any): @@ -29,7 +39,12 @@ def coerce_compared_value(self, op: Any, value: Any): return self.impl().coerce_compared_value(op=op, value=value) def process_bind_param(self, value: scn.LevelScenario | None, dialect: Dialect): - return self.retort.dump(value, scn.LevelScenario) + try: + dumped = self.retort.dump(value, scn.LevelScenario) + except Exception as e: + logger.exception("can't dump level scenario", exc_info=e) + raise + return dumped def process_result_value(self, value: Any, dialect: Dialect) -> scn.LevelScenario | None: if value is None: diff --git a/shvatka/infrastructure/scheduler/wrappers.py b/shvatka/infrastructure/scheduler/wrappers.py index 821974cf..5e815194 100644 --- a/shvatka/infrastructure/scheduler/wrappers.py +++ b/shvatka/infrastructure/scheduler/wrappers.py @@ -9,6 +9,7 @@ from shvatka.core.services.game_play import prepare_game, start_game, send_hint from shvatka.core.services.level_testing import send_testing_level_hint from shvatka.core.services.organizers import get_by_player +from shvatka.tgbot.views.bot_alert import BotAlert @inject @@ -35,16 +36,21 @@ async def start_game_wrapper( scheduler: FromDishka[Scheduler], game_view: FromDishka[GameView], game_log_writer: FromDishka[GameLogWriter], + alerter: FromDishka[BotAlert], ): - game = await dao.game.get_full(game_id) - assert author_id == game.author.id - await start_game( - game=game, - dao=dao.game_starter, - game_log=game_log_writer, - view=game_view, - scheduler=scheduler, - ) + try: + game = await dao.game.get_full(game_id) + assert author_id == game.author.id + await start_game( + game=game, + dao=dao.game_starter, + game_log=game_log_writer, + view=game_view, + scheduler=scheduler, + ) + except Exception as e: + await alerter.alert(f"game not started because of {e!s}") + raise @inject @@ -55,18 +61,23 @@ async def send_hint_wrapper( dao: FromDishka[HolderDao], game_view: FromDishka[GameView], scheduler: FromDishka[Scheduler], + alerter: FromDishka[BotAlert], ): - level = await dao.level.get_by_id(level_id) - team = await dao.team.get_by_id(team_id) + try: + level = await dao.level.get_by_id(level_id) + team = await dao.team.get_by_id(team_id) - await send_hint( - level=level, - hint_number=hint_number, - team=team, - dao=dao.level_time, - view=game_view, - scheduler=scheduler, - ) + await send_hint( + level=level, + hint_number=hint_number, + team=team, + dao=dao.level_time, + view=game_view, + scheduler=scheduler, + ) + except Exception as e: + await alerter.alert(f"hint for team {team_id} not sent because of {e!s}") + raise @inject diff --git a/shvatka/tgbot/dialogs/level_scn/getters.py b/shvatka/tgbot/dialogs/level_scn/getters.py index 8057fd62..943fdadb 100644 --- a/shvatka/tgbot/dialogs/level_scn/getters.py +++ b/shvatka/tgbot/dialogs/level_scn/getters.py @@ -1,7 +1,7 @@ from adaptix import Retort from aiogram_dialog import DialogManager -from shvatka.core.models.dto import scn +import shvatka.core.models.dto.action.keys from shvatka.core.models.dto.scn import TimeHint @@ -24,7 +24,7 @@ async def get_bonus_keys(dialog_manager: DialogManager, **_): "bonus_keys", dialog_manager.start_data.get("bonus_keys", []) ) return { - "bonus_keys": retort.load(keys_raw, list[scn.BonusKey]), + "bonus_keys": retort.load(keys_raw, list[shvatka.core.models.dto.action.keys.BonusKey]), } diff --git a/shvatka/tgbot/dialogs/level_scn/handlers.py b/shvatka/tgbot/dialogs/level_scn/handlers.py index 5648bb65..5ad890f5 100644 --- a/shvatka/tgbot/dialogs/level_scn/handlers.py +++ b/shvatka/tgbot/dialogs/level_scn/handlers.py @@ -5,8 +5,9 @@ from aiogram_dialog import Data, DialogManager from aiogram_dialog.widgets.kbd import Button +import shvatka.core.models.dto.action.keys from shvatka.core.models import dto -from shvatka.core.models.dto import scn +from shvatka.core.models.dto import scn, action from shvatka.core.services.level import upsert_level, get_by_id from shvatka.core.utils.input_validation import ( is_multiple_keys_normal, @@ -69,7 +70,7 @@ async def on_correct_keys(m: Message, dialog_: Any, manager: DialogManager, keys await manager.done({"keys": keys}) -def convert_bonus_keys(text: str) -> list[scn.BonusKey]: +def convert_bonus_keys(text: str) -> list[shvatka.core.models.dto.action.keys.BonusKey]: result = [] for key_str in text.splitlines(): key, bonus = key_str.split(maxsplit=1) @@ -78,7 +79,9 @@ def convert_bonus_keys(text: str) -> list[scn.BonusKey]: parsed_bonus = float(bonus) if not (-600 < parsed_bonus < 60): raise ValueError("bonus out of available range") - parsed_key = scn.BonusKey(text=key, bonus_minutes=parsed_bonus) + parsed_key = shvatka.core.models.dto.action.keys.BonusKey( + text=key, bonus_minutes=parsed_bonus + ) result.append(parsed_key) return result @@ -94,10 +97,13 @@ async def not_correct_bonus_keys( async def on_correct_bonus_keys( - m: Message, dialog_: Any, manager: DialogManager, keys: list[scn.BonusKey] + m: Message, + dialog_: Any, + manager: DialogManager, + keys: list[action.BonusKey], ): retort: Retort = manager.middleware_data["retort"] - await manager.done({"bonus_keys": retort.dump(keys)}) + await manager.done({"bonus_keys": retort.dump(keys, list[action.BonusKey])}) async def process_time_hint_result(start_data: Data, result: Any, manager: DialogManager): @@ -139,7 +145,9 @@ async def on_start_level_edit(start_data: dict[str, Any], manager: DialogManager manager.dialog_data["level_id"] = level.name_id manager.dialog_data["keys"] = list(level.get_keys()) manager.dialog_data["time_hints"] = retort.dump(level.scenario.time_hints) - manager.dialog_data["bonus_keys"] = retort.dump(level.get_bonus_keys(), set[scn.BonusKey]) + manager.dialog_data["bonus_keys"] = retort.dump( + list(level.get_bonus_keys()), list[action.BonusKey] + ) async def on_start_hints_edit(start_data: dict[str, Any], manager: DialogManager): @@ -210,9 +218,13 @@ async def save_level(c: CallbackQuery, button: Button, manager: DialogManager): id_ = data["level_id"] keys = set(map(normalize_key, data["keys"])) time_hints = retort.load(data["time_hints"], list[scn.TimeHint]) - bonus_keys = retort.load(data.get("bonus_keys", []), set[scn.BonusKey]) + bonus_keys = retort.load( + data.get("bonus_keys", []), set[shvatka.core.models.dto.action.keys.BonusKey] + ) - level_scn = scn.LevelScenario(id=id_, keys=keys, time_hints=time_hints, bonus_keys=bonus_keys) + level_scn = scn.LevelScenario.legacy_factory( + id=id_, keys=keys, time_hints=time_hints, bonus_keys=bonus_keys + ) level = await upsert_level(author=author, scenario=level_scn, dao=dao.level) await manager.done(result={"level": retort.dump(level)}) await c.answer(text="Уровень успешно сохранён") diff --git a/shvatka/tgbot/views/results/scenario.py b/shvatka/tgbot/views/results/scenario.py index d6d7aafd..495092ec 100644 --- a/shvatka/tgbot/views/results/scenario.py +++ b/shvatka/tgbot/views/results/scenario.py @@ -100,8 +100,15 @@ async def publish(self): if hint.time == 0: text = ( f"🔒 Уровень № {self.level.number_in_game + 1}\n" - f"Ключи уровня:\n🔑 " + "\n🔑 ".join(self.level.scenario.keys) + f"Ключи уровня:\n🔑 " + "\n🔑 ".join(self.level.scenario.get_keys()) ) + 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/resources/all_types.yml b/tests/fixtures/resources/all_types.yml index b0095c8e..387b65ef 100644 --- a/tests/fixtures/resources/all_types.yml +++ b/tests/fixtures/resources/all_types.yml @@ -1,8 +1,14 @@ +__model_version__: 1 name: "All types" levels: - id: first - keys: - - "SH123" + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: + - "SH123" + - type: BONUS_KEY + keys: [] time-hints: - time: 0 hint: diff --git a/tests/fixtures/resources/complex_scn.yml b/tests/fixtures/resources/complex_scn.yml index a76723ec..1622dbd5 100644 --- a/tests/fixtures/resources/complex_scn.yml +++ b/tests/fixtures/resources/complex_scn.yml @@ -1,9 +1,17 @@ name: "My new game" +__model_version__: 1 levels: - id: first - keys: - - "SH123" - - "SH321" + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: + - "SH123" + - "SH321" + - type: BONUS_KEY + keys: + - text: "SHBONUS" + bonus-minutes: 10 time-hints: - time: 0 hint: @@ -26,7 +34,10 @@ levels: - time: text text: "SH123\nSH321" - id: second - keys: [ "SHOOT" ] + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: [ "SHOOT" ] time-hints: - time: 0 hint: diff --git a/tests/fixtures/resources/simple_scn.yml b/tests/fixtures/resources/simple_scn.yml index 4448c963..cfde29e0 100644 --- a/tests/fixtures/resources/simple_scn.yml +++ b/tests/fixtures/resources/simple_scn.yml @@ -1,9 +1,13 @@ +__model_version__: 1 name: "My new game" levels: - id: first - keys: - - "SH123" - - "SH321" + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: + - "SH123" + - "SH321" time-hints: - time: 0 hint: @@ -25,7 +29,10 @@ levels: - time: text text: "SH123\nSH321" - id: second - keys: [ "SHOOT" ] + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: [ "SHOOT" ] time-hints: - time: 0 hint: diff --git a/tests/fixtures/resources/three_lvl_scn.yml b/tests/fixtures/resources/three_lvl_scn.yml index b5d6f7b6..6859848d 100644 --- a/tests/fixtures/resources/three_lvl_scn.yml +++ b/tests/fixtures/resources/three_lvl_scn.yml @@ -1,9 +1,13 @@ +__model_version__: 1 name: "My new game" levels: - id: first - keys: - - "SH123" - - "SH321" + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: + - "SH123" + - "SH321" time-hints: - time: 0 hint: @@ -25,7 +29,10 @@ levels: - time: text text: "SH123\nSH321" - id: second - keys: [ "SHOOT" ] + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: [ "SHOOT" ] time-hints: - time: 0 hint: @@ -48,7 +55,10 @@ levels: - time: text text: "SHOOT" - id: third - keys: [ "SHARP" ] + __model_version__: 1 + conditions: + - type: WIN_KEY + keys: [ "SHARP" ] time-hints: - time: 0 hint: diff --git a/tests/integration/bot_full/test_level_writer.py b/tests/integration/bot_full/test_level_writer.py index 28bdd7db..c5bb8924 100644 --- a/tests/integration/bot_full/test_level_writer.py +++ b/tests/integration/bot_full/test_level_writer.py @@ -166,6 +166,6 @@ async def test_write_level( assert 1 == await dao.level.count() level, *_ = await dao.level.get_all_my(author) assert author.id == level.author.id - assert {"SHTESTKEY"} == level.scenario.keys + assert {"SHTESTKEY"} == level.scenario.get_keys() assert 2 == level.hints_count bot.auto_mock_success = False diff --git a/tests/integration/test_level.py b/tests/integration/test_level.py index be4fc54f..ed4a5e83 100644 --- a/tests/integration/test_level.py +++ b/tests/integration/test_level.py @@ -27,4 +27,4 @@ async def test_simple_level(simple_scn: RawGameScenario, dao: HolderDao, retort: assert lvl.game_id is None assert lvl.number_in_game is None - assert lvl.scenario.keys == {"SH123", "SH321"} + assert lvl.scenario.get_keys() == {"SH123", "SH321"} diff --git a/tests/unit/domain/conditions_test.py b/tests/unit/domain/conditions_test.py new file mode 100644 index 00000000..c4648389 --- /dev/null +++ b/tests/unit/domain/conditions_test.py @@ -0,0 +1,114 @@ +import pytest + +from shvatka.core.models.dto.action import keys +from shvatka.core.models.dto import scn +from shvatka.core.models.dto import action +from shvatka.core.utils import exceptions + + +@pytest.fixture +def complex_conditions() -> scn.Conditions: + return scn.Conditions( + [ + action.KeyWinCondition({keys.SHKey("SH123"), keys.SHKey("SH321")}), + action.KeyBonusCondition( + { + keys.BonusKey(text="SHB1", bonus_minutes=1), + keys.BonusKey(text="SHB2", bonus_minutes=-1), + } + ), + action.KeyBonusCondition({keys.BonusKey(text="SHB3", bonus_minutes=0)}), + action.KeyWinCondition({keys.SHKey("СХ123")}), + ] + ) + + +def test_create_one_key(): + c = scn.Conditions([action.KeyWinCondition({keys.SHKey("SH321")})]) + assert len(c) == 1 + assert len(c.get_keys()) == 1 + assert len(c.get_bonus_keys()) == 0 + actual = c[0] + assert isinstance(actual, action.KeyWinCondition) + assert actual.keys == {keys.SHKey("SH321")} + assert c.get_keys() == {keys.SHKey("SH321")} + + +def test_create_empty_condition(): + with pytest.raises(exceptions.LevelError): + scn.Conditions([]) + + +def test_create_only_bonus_condition(): + with pytest.raises(exceptions.LevelError): + scn.Conditions([action.KeyBonusCondition({keys.BonusKey(text="SH123", bonus_minutes=1)})]) + + +def test_conditions_get_keys(): + c = scn.Conditions( + [ + action.KeyWinCondition({keys.SHKey("SH123"), keys.SHKey("SH321")}), + action.KeyWinCondition({keys.SHKey("СХ123")}), + ] + ) + assert c.get_keys() == {keys.SHKey("SH123"), keys.SHKey("SH321"), keys.SHKey("СХ123")} + + +def test_conditions_get_keys_with_bonus(complex_conditions: scn.Conditions): + assert complex_conditions.get_keys() == { + keys.SHKey("SH123"), + keys.SHKey("SH321"), + keys.SHKey("СХ123"), + } + + +def test_conditions_get_bonus_keys(complex_conditions: scn.Conditions): + assert complex_conditions.get_bonus_keys() == { + keys.BonusKey(text="SHB1", bonus_minutes=1), + keys.BonusKey(text="SHB2", bonus_minutes=-1), + keys.BonusKey(text="SHB3", bonus_minutes=0), + } + + +def test_conditions_duplicate_keys(): + with pytest.raises(exceptions.LevelError): + scn.Conditions( + [ + action.KeyWinCondition({keys.SHKey("SH123"), keys.SHKey("SH321")}), + action.KeyWinCondition({keys.SHKey("СХ123")}), + action.KeyWinCondition({keys.SHKey("SH321")}), + ] + ) + + +def test_conditions_duplicate_bonus_keys(): + with pytest.raises(exceptions.LevelError): + scn.Conditions( + [ + action.KeyWinCondition({keys.SHKey("SH123")}), + action.KeyBonusCondition( + { + keys.BonusKey(text="SHB1", bonus_minutes=1), + keys.BonusKey(text="SH123", bonus_minutes=-1), + } + ), + action.KeyBonusCondition({keys.BonusKey(text="SHB3", bonus_minutes=0)}), + ] + ) + + +def test_conditions_duplicate_both_keys(): + with pytest.raises(exceptions.LevelError): + scn.Conditions( + [ + action.KeyWinCondition({keys.SHKey("SH123"), keys.SHKey("SH321")}), + action.KeyWinCondition({keys.SHKey("СХ123")}), + action.KeyBonusCondition( + { + keys.BonusKey(text="SHB1", bonus_minutes=1), + keys.BonusKey(text="СХ123", bonus_minutes=-1), + } + ), + action.KeyBonusCondition({keys.BonusKey(text="SHB3", bonus_minutes=0)}), + ] + ) diff --git a/tests/unit/domain/level_test.py b/tests/unit/domain/level_test.py new file mode 100644 index 00000000..8514845b --- /dev/null +++ b/tests/unit/domain/level_test.py @@ -0,0 +1,167 @@ +import pytest + +from shvatka.core.models import enums +from shvatka.core.models.dto import scn, action +from shvatka.core.utils import exceptions + + +@pytest.fixture +def hints() -> scn.HintsList: + return scn.HintsList( + [ + scn.TimeHint(time=0, hint=[scn.TextHint("hint")]), + scn.TimeHint(time=5, hint=[scn.TextHint("other hint")]), + ] + ) + + +@pytest.fixture +def level_one_key(hints: scn.HintsList) -> scn.LevelScenario: + return scn.LevelScenario( + id="test1", + time_hints=hints, + conditions=scn.Conditions( + [ + action.KeyWinCondition({"SH123"}), + action.KeyBonusCondition({action.BonusKey(text="SHB1", bonus_minutes=1)}), + ] + ), + __model_version__=1, + ) + + +@pytest.fixture +def level_three_keys(hints: scn.HintsList) -> scn.LevelScenario: + return scn.LevelScenario( + id="test2", + time_hints=hints, + conditions=scn.Conditions( + [ + action.KeyWinCondition({"SH123", "SH321", "СХ123"}), + action.KeyBonusCondition({action.BonusKey(text="SHB1", bonus_minutes=1)}), + ] + ), + __model_version__=1, + ) + + +def test_create_level_without_conditions(hints: scn.HintsList): + with pytest.raises(exceptions.LevelError): + scn.LevelScenario( + id="test", + time_hints=hints, + conditions=[], # type: ignore + __model_version__=1, + ) + + +def test_win_level_single_key(level_one_key: scn.LevelScenario): + decision = level_one_key.check( + action.TypedKeyAction("SH123"), action.InMemoryStateHolder(set(), set()) + ) + + assert isinstance(decision, action.KeyDecision) + assert decision.key == "SH123" + assert decision.key_text == "SH123" + assert decision.key_type == enums.KeyType.simple + assert decision.type == action.DecisionType.LEVEL_UP + assert decision.is_level_up() + assert not decision.duplicate + + +def test_wrong_level_single_key(level_one_key: scn.LevelScenario): + decision = level_one_key.check( + action.TypedKeyAction("SHWRONG"), action.InMemoryStateHolder(set(), set()) + ) + + assert isinstance(decision, action.WrongKeyDecision) + assert decision.key == "SHWRONG" + assert decision.key_text == "SHWRONG" + assert decision.key_type == enums.KeyType.wrong + assert decision.type == action.DecisionType.NO_ACTION + assert not decision.duplicate + + +def test_duplicate_wrong_level_single_key(level_one_key: scn.LevelScenario): + decision = level_one_key.check( + action.TypedKeyAction("SHWRONG"), action.InMemoryStateHolder(set(), {"SHWRONG"}) + ) + + assert isinstance(decision, action.WrongKeyDecision) + assert decision.key == "SHWRONG" + assert decision.key_text == "SHWRONG" + assert decision.key_type == enums.KeyType.wrong + assert decision.type == action.DecisionType.NO_ACTION + assert decision.duplicate + + +def test_bonus_level_single_key(level_one_key: scn.LevelScenario): + decision = level_one_key.check( + action.TypedKeyAction("SHB1"), action.InMemoryStateHolder(set(), {"SHWRONG"}) + ) + + assert isinstance(decision, action.BonusKeyDecision) + assert decision.key.text == "SHB1" + assert decision.key.bonus_minutes == 1 + assert decision.key_text == "SHB1" + assert decision.key_type == enums.KeyType.bonus + assert decision.type == action.DecisionType.BONUS_TIME + assert not decision.duplicate + + +def test_duplicate_bonus_level_single_key(level_one_key: scn.LevelScenario): + decision = level_one_key.check( + action.TypedKeyAction("SHB1"), action.InMemoryStateHolder(set(), {"SHWRONG", "SHB1"}) + ) + + assert isinstance(decision, action.BonusKeyDecision) + assert decision.key.text == "SHB1" + assert decision.key.bonus_minutes == 1 + assert decision.key_text == "SHB1" + assert decision.key_type == enums.KeyType.bonus + assert decision.type == action.DecisionType.BONUS_TIME + assert decision.duplicate + + +def test_second_key_of_three(level_three_keys: scn.LevelScenario): + decision = level_three_keys.check( + action.TypedKeyAction("SH123"), action.InMemoryStateHolder({"SH321"}, {"SH321"}) + ) + + assert isinstance(decision, action.KeyDecision) + assert decision.key == "SH123" + assert decision.key_text == "SH123" + assert decision.key_type == enums.KeyType.simple + assert decision.type == action.DecisionType.SIGNIFICANT_ACTION + assert not decision.is_level_up() + assert not decision.duplicate + + +def test_duplicate_second_key_of_three(level_three_keys: scn.LevelScenario): + decision = level_three_keys.check( + action.TypedKeyAction("SH123"), + action.InMemoryStateHolder({"SH321", "SH123"}, {"SH321", "SH123"}), + ) + + assert isinstance(decision, action.KeyDecision) + assert decision.key == "SH123" + assert decision.key_text == "SH123" + assert decision.key_type == enums.KeyType.simple + assert decision.type == action.DecisionType.NO_ACTION + assert not decision.is_level_up() + assert decision.duplicate + + +def test_third_key_of_three(level_three_keys: scn.LevelScenario): + decision = level_three_keys.check( + action.TypedKeyAction("SH123"), + action.InMemoryStateHolder({"SH321", "СХ123"}, {"SH321", "СХ123"}), + ) + + assert isinstance(decision, action.KeyDecision) + assert decision.key == "SH123" + assert decision.key_text == "SH123" + assert decision.key_type == enums.KeyType.simple + assert decision.type == action.DecisionType.LEVEL_UP + assert decision.is_level_up() + assert not decision.duplicate diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/services/key_test.py b/tests/unit/services/key_test.py new file mode 100644 index 00000000..3dc05d5b --- /dev/null +++ b/tests/unit/services/key_test.py @@ -0,0 +1,36 @@ +from shvatka.core.models.dto import action +from shvatka.core.services.key import decision_to_parsed_key +from shvatka.core.models import enums, dto + + +def test_decision_to_simple_parsed_key(): + assert decision_to_parsed_key( + action.KeyDecision( + type=action.DecisionType.NO_ACTION, + key_type=enums.KeyType.simple, + duplicate=False, + key="SH123", + ) + ) == dto.ParsedKey(type_=enums.KeyType.simple, text="SH123") + + +def test_decision_to_bonus_parsed_key(): + assert decision_to_parsed_key( + action.BonusKeyDecision( + type=action.DecisionType.BONUS_TIME, + key_type=enums.KeyType.bonus, + duplicate=False, + key=action.BonusKey(text="SH123", bonus_minutes=10), + ) + ) == dto.ParsedBonusKey(type_=enums.KeyType.bonus, text="SH123", bonus_minutes=10) + + +def test_decision_to_wrong_parsed_key(): + assert decision_to_parsed_key( + action.WrongKeyDecision( + type=action.DecisionType.NO_ACTION, + key_type=enums.KeyType.wrong, + duplicate=False, + key="SH123", + ) + ) == dto.ParsedKey(type_=enums.KeyType.wrong, text="SH123")