diff --git a/deadlock_assets_api/deploy.py b/deadlock_assets_api/deploy.py index 9c95673..be9f09e 100644 --- a/deadlock_assets_api/deploy.py +++ b/deadlock_assets_api/deploy.py @@ -9,6 +9,7 @@ from pydantic import TypeAdapter from deadlock_assets_api.glob import FONTS_BASE_URL, SOUNDS_BASE_URL, SVGS_BASE_URL, IMAGE_BASE_URL +from deadlock_assets_api.historical import backfill_historical_generic_data from deadlock_assets_api.main import app from deadlock_assets_api.models.languages import Language from deadlock_assets_api.models.v1.colors import ColorV1 @@ -316,6 +317,8 @@ def item_from_raw_item(raw_item: RawUpgradeV2 | RawAbilityV2 | RawWeaponV2) -> I with open(f"{out_folder}/versions/{version_id}/images_data.json", "w") as f: json.dump(images_data, f) + backfill_historical_generic_data(out_folder, client_versions) + with open(f"{out_folder}/versions/{version_id}/raw_heroes.json", "w") as f: json.dump([h.model_dump(exclude_none=True) for h in raw_heroes], f) diff --git a/deadlock_assets_api/historical.py b/deadlock_assets_api/historical.py new file mode 100644 index 0000000..3bf99bb --- /dev/null +++ b/deadlock_assets_api/historical.py @@ -0,0 +1,19 @@ +import os + +from deadlock_assets_api.models.v2.generic_data import GenericDataV2 + + +def backfill_historical_generic_data(out_folder: str, client_versions: list[int]) -> None: + for version_id in client_versions: + version_folder = f"{out_folder}/versions/{version_id}" + os.makedirs(version_folder, exist_ok=True) + + generic_source = f"res/builds/{version_id}/v2/generic_data.json" + generic_target = f"{version_folder}/generic_data.json" + if not os.path.exists(generic_source) or os.path.exists(generic_target): + continue + + with open(generic_source) as f: + generic_data = GenericDataV2.model_validate_json(f.read()) + with open(generic_target, "w") as f: + f.write(generic_data.model_dump_json(exclude_none=True)) diff --git a/deadlock_assets_api/models/v2/generic_data.py b/deadlock_assets_api/models/v2/generic_data.py index fcf1ad7..3203b82 100644 --- a/deadlock_assets_api/models/v2/generic_data.py +++ b/deadlock_assets_api/models/v2/generic_data.py @@ -1,7 +1,7 @@ import os from functools import lru_cache -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator from deadlock_assets_api.models.v1.colors import ColorV1 from deadlock_assets_api.models.v2.enums import ItemTierV2 @@ -154,8 +154,14 @@ class OutcomeToWeights(BaseModel): class ItemDraftRound(BaseModel): model_config = ConfigDict(populate_by_name=True) - normal_mod_tier: ItemTierV2 = Field(..., validation_alias="m_eNormalModTier") - rare_mod_tier: ItemTierV2 = Field(..., validation_alias="m_eRareModTier") + normal_mod_tier: ItemTierV2 = Field( + ..., + validation_alias=AliasChoices("m_eNormalModTier", "normal_mod_tier", "chance_enhanced"), + ) + rare_mod_tier: ItemTierV2 = Field( + ..., + validation_alias=AliasChoices("m_eRareModTier", "rare_mod_tier", "chance_rare"), + ) class ItemDraftRoundPerGameRound(BaseModel): @@ -233,8 +239,8 @@ class StreetBrawl(BaseModel): outline_color_team1: list[int] | None = Field(None, validation_alias="m_OutlineColorTeam1") outline_color_team2: list[int] | None = Field(None, validation_alias="m_OutlineColorTeam2") outline_color_neutral: list[int] | None = Field(None, validation_alias="m_OutlineColorNeutral") - item_drafts: dict[ItemTierV2, DraftBuckets | None] = Field( - ..., validation_alias="m_mapItemTierToItemDraftBuckets" + item_drafts: dict[ItemTierV2, DraftBuckets | None] | None = Field( + None, validation_alias="m_mapItemTierToItemDraftBuckets" ) @@ -243,7 +249,7 @@ class GenericDataV2(BaseModel): damage_flash: DamageFlashV2 = Field(..., validation_alias="m_mapDamageFlash") glitch_settings: GlitchSettingsV2 = Field(..., validation_alias="m_GlitchSettings") - lane_info: list[LaneInfoV2] = Field(..., validation_alias="m_LaneInfo") + lane_info: list[LaneInfoV2] | None = Field(None, validation_alias="m_LaneInfo") new_player_metrics: list[NewPlayerMetricsV2] = Field(..., validation_alias="m_NewPlayerMetrics") minimap_team_rebels_color: ColorV1 | None = Field( None, validation_alias="m_MinimapTeamRebelsColor" @@ -257,22 +263,22 @@ class GenericDataV2(BaseModel): enemy_objectives_color: ColorV1 | None = Field(None, validation_alias="m_enemyObjectivesColor") enemy_zipline_color: ColorV1 | None = Field(None, validation_alias="m_enemyZiplineColor") item_price_per_tier: list[int] = Field(..., validation_alias="m_nItemPricePerTier") - trooper_kill_gold_share_frac: list[float] = Field( - ..., validation_alias="m_flTrooperKillGoldShareFrac" + trooper_kill_gold_share_frac: list[float] | None = Field( + None, validation_alias="m_flTrooperKillGoldShareFrac" ) - hero_kill_gold_share_frac: list[float] = Field( - ..., validation_alias="m_flHeroKillGoldShareFrac" + hero_kill_gold_share_frac: list[float] | None = Field( + None, validation_alias="m_flHeroKillGoldShareFrac" ) - aim_spring_strength: list[float] = Field(..., validation_alias="m_AimSpringStrength") - targeting_spring_strength: list[float] = Field( - ..., validation_alias="m_TargetingSpringStrength" + aim_spring_strength: list[float] | None = Field(None, validation_alias="m_AimSpringStrength") + targeting_spring_strength: list[float] | None = Field( + None, validation_alias="m_TargetingSpringStrength" ) - objective_params: ObjectiveParams = Field(..., validation_alias="m_ObjectiveParams") - rejuv_params: RejuvParams = Field(..., validation_alias="m_RejuvParams") - mini_map_offsets: list[MiniMapOffsets] = Field(..., validation_alias="m_MiniMapOffsets") - weapon_groups: list[ItemGroup] = Field(..., validation_alias="m_vecWeaponGroups") - armor_groups: list[ItemGroup] = Field(..., validation_alias="m_vecArmorGroups") - spirit_groups: list[ItemGroup] = Field(..., validation_alias="m_vecSpiritGroups") + objective_params: ObjectiveParams | None = Field(None, validation_alias="m_ObjectiveParams") + rejuv_params: RejuvParams | None = Field(None, validation_alias="m_RejuvParams") + mini_map_offsets: list[MiniMapOffsets] | None = Field(None, validation_alias="m_MiniMapOffsets") + weapon_groups: list[ItemGroup] | None = Field(None, validation_alias="m_vecWeaponGroups") + armor_groups: list[ItemGroup] | None = Field(None, validation_alias="m_vecArmorGroups") + spirit_groups: list[ItemGroup] | None = Field(None, validation_alias="m_vecSpiritGroups") street_brawl: StreetBrawl | None = Field(None, validation_alias="m_StreetBrawl") @field_validator( diff --git a/deadlock_assets_api/utils.py b/deadlock_assets_api/utils.py index 08bf7b2..02e9d81 100644 --- a/deadlock_assets_api/utils.py +++ b/deadlock_assets_api/utils.py @@ -83,15 +83,21 @@ def strip_prefix(string: str, prefix: str) -> str: @lru_cache(maxsize=DATA_CACHE_MAXSIZE) def read_parse_data_ta[T](filepath: str, type_adapter: TypeAdapter[T]) -> T: LOGGER.debug(f"Reading {filepath}") - with open(filepath) as f: - return type_adapter.validate_json(f.read()) + try: + with open(filepath) as f: + return type_adapter.validate_json(f.read()) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Data file not found: {filepath}") from exc @lru_cache(maxsize=DATA_CACHE_MAXSIZE) def read_parse_data_model[T: BaseModel](filepath: str, model: type[T]) -> T: LOGGER.debug(f"Reading {filepath}") - with open(filepath) as f: - return model.model_validate_json(f.read()) + try: + with open(filepath) as f: + return model.model_validate_json(f.read()) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Data file not found: {filepath}") from exc def validate_client_version(client_version: ValidClientVersions | None = None) -> int: diff --git a/tests/test_historical_assets.py b/tests/test_historical_assets.py new file mode 100644 index 0000000..25e4954 --- /dev/null +++ b/tests/test_historical_assets.py @@ -0,0 +1,93 @@ +import importlib +import json +import sys +import types +from pathlib import Path + +import pytest +from fastapi import HTTPException +from pydantic import TypeAdapter + +from deadlock_assets_api.models.v2.generic_data import GenericDataV2 + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def prepare_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + deploy = tmp_path / "deploy" + deploy.mkdir() + (deploy / "client_versions.json").write_text(json.dumps([6016, 5959])) + + css_parser = types.ModuleType("css_parser") + css_parser.parseFile = lambda *_args, **_kwargs: types.SimpleNamespace(cssRules=[]) + css_parser.parseString = lambda *_args, **_kwargs: types.SimpleNamespace(cssRules=[]) + css_module = types.ModuleType("css_parser.css") + css_module.CSSRuleList = list + css_module.CSSStyleRule = type("CSSStyleRule", (), {}) + css_module.ColorValue = type("ColorValue", (), {}) + css_module.CSSUnknownRule = type("CSSUnknownRule", (), {}) + monkeypatch.setitem(sys.modules, "css_parser", css_parser) + monkeypatch.setitem(sys.modules, "css_parser.css", css_module) + stringcase = types.ModuleType("stringcase") + stringcase.snakecase = lambda value: value + monkeypatch.setitem(sys.modules, "stringcase", stringcase) + + for module_name in [ + "deadlock_assets_api.models.enums", + "deadlock_assets_api.utils", + ]: + sys.modules.pop(module_name, None) + + +def test_generic_data_model_accepts_old_minimal_snapshots() -> None: + payload = json.loads((REPO_ROOT / "res/builds/5959/v2/generic_data.json").read_text()) + + generic_data = GenericDataV2.model_validate(payload) + + assert generic_data.item_price_per_tier == [0, 800, 1600, 3200, 6400, 9999] + assert generic_data.lane_info is None + assert generic_data.weapon_groups is None + + +def test_generic_data_model_accepts_legacy_street_brawl_draft_shape() -> None: + payload = json.loads((REPO_ROOT / "res/builds/6351/v2/generic_data.json").read_text()) + + generic_data = GenericDataV2.model_validate(payload) + + first_round = generic_data.street_brawl.item_draft_rounds_per_game_round[0].item_draft_rounds[0] + assert first_round.normal_mod_tier.value == 2 + assert first_round.rare_mod_tier.value == 1 + assert generic_data.street_brawl.item_drafts is None + + +def test_missing_generated_file_returns_404_instead_of_500( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_runtime(tmp_path, monkeypatch) + utils = importlib.import_module("deadlock_assets_api.utils") + + with pytest.raises(HTTPException) as exc_info: + utils.read_parse_data_ta( + "deploy/versions/5959/images_data.json", TypeAdapter(dict[str, str]) + ) + + assert exc_info.value.status_code == 404 + + +def test_backfills_historical_generic_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + prepare_runtime(tmp_path, monkeypatch) + source_folder = tmp_path / "res/builds/5959/v2" + source_folder.mkdir(parents=True) + source_folder.joinpath("generic_data.json").write_text( + (REPO_ROOT / "res/builds/5959/v2/generic_data.json").read_text() + ) + + historical = importlib.import_module("deadlock_assets_api.historical") + + historical.backfill_historical_generic_data("deploy", [6016, 5959]) + + generic_data = json.loads((tmp_path / "deploy/versions/5959/generic_data.json").read_text()) + assert generic_data["item_price_per_tier"] == [0, 800, 1600, 3200, 6400, 9999] + assert "lane_info" not in generic_data