Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deadlock_assets_api/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions deadlock_assets_api/historical.py
Original file line number Diff line number Diff line change
@@ -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))
44 changes: 25 additions & 19 deletions deadlock_assets_api/models/v2/generic_data.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of setting default to empty dicts/list I would much rather have them be None

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

item_drafts: dict[ItemTierV2, DraftBuckets | None] | None = Field(
None, validation_alias="m_mapItemTierToItemDraftBuckets"
)


Expand All @@ -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"
Expand All @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions deadlock_assets_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
93 changes: 93 additions & 0 deletions tests/test_historical_assets.py
Original file line number Diff line number Diff line change
@@ -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
Loading