From 9e51c92b3448175f63ed51aa8fad4095452f32ec Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 00:26:53 +0200 Subject: [PATCH] M2: set17.toml game constants + validating loader Co-Authored-By: Claude Fable 5 --- backend/tests/test_constants.py | 42 +++++++++++++++++ backend/tft/constants/__init__.py | 0 backend/tft/constants/loader.py | 37 +++++++++++++++ backend/tft/constants/set17.toml | 77 +++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 backend/tests/test_constants.py create mode 100644 backend/tft/constants/__init__.py create mode 100644 backend/tft/constants/loader.py create mode 100644 backend/tft/constants/set17.toml diff --git a/backend/tests/test_constants.py b/backend/tests/test_constants.py new file mode 100644 index 0000000..8a07c51 --- /dev/null +++ b/backend/tests/test_constants.py @@ -0,0 +1,42 @@ +import pytest + +from tft.constants.loader import load_constants, validate + + +@pytest.fixture +def c(): + return load_constants(17) + + +def test_loads_and_validates(c): + assert c["set"] == 17 + + +def test_shop_odds_shape(c): + assert len(c["shop"]["odds"]) == c["xp"]["max_level"] + for row in c["shop"]["odds"]: + assert sum(row) == 100 + + +def test_xp_reaches_max_level(c): + assert len(c["xp"]["to_next"]) == c["xp"]["max_level"] - 1 + assert all(x > 0 for x in c["xp"]["to_next"]) + + +def test_interest_and_streaks(c): + g = c["gold"] + assert g["interest_cap"] >= 1 + streaks = g["streak_gold"] + assert streaks == sorted(streaks) + assert all(gold > 0 for _, gold in streaks) + + +def test_missing_set_raises(): + with pytest.raises(FileNotFoundError): + load_constants(99) + + +def test_validation_catches_bad_odds(c): + c["shop"]["odds"][0] = [99, 0, 0, 0, 0] + with pytest.raises(ValueError, match="sum to 99"): + validate(c) diff --git a/backend/tft/constants/__init__.py b/backend/tft/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tft/constants/loader.py b/backend/tft/constants/loader.py new file mode 100644 index 0000000..0f41e8f --- /dev/null +++ b/backend/tft/constants/loader.py @@ -0,0 +1,37 @@ +import tomllib +from pathlib import Path + +CONSTANTS_DIR = Path(__file__).parent + + +def load_constants(set_number: int) -> dict: + path = CONSTANTS_DIR / f"set{set_number}.toml" + if not path.exists(): + raise FileNotFoundError( + f"no constants for set {set_number}: create {path} " + f"(copy the previous set's file and adjust from patch notes)" + ) + with path.open("rb") as f: + c = tomllib.load(f) + validate(c) + return c + + +def validate(c: dict) -> None: + for level, row in enumerate(c["shop"]["odds"], start=1): + if sum(row) != 100: + raise ValueError(f"shop odds for level {level} sum to {sum(row)}, not 100") + if len(row) != 5: + raise ValueError(f"shop odds for level {level} must have 5 cost tiers") + if len(c["pool"]["sizes"]) != 5: + raise ValueError("pool sizes must have 5 cost tiers") + xp = c["xp"] + if len(xp["to_next"]) != xp["max_level"] - 1: + raise ValueError( + f"xp.to_next needs {xp['max_level'] - 1} entries, has {len(xp['to_next'])}" + ) + if len(c["shop"]["odds"]) != xp["max_level"]: + raise ValueError("shop odds must have one row per level") + streaks = c["gold"]["streak_gold"] + if streaks != sorted(streaks): + raise ValueError("streak_gold must be sorted by streak length") diff --git a/backend/tft/constants/set17.toml b/backend/tft/constants/set17.toml new file mode 100644 index 0000000..d1e1138 --- /dev/null +++ b/backend/tft/constants/set17.toml @@ -0,0 +1,77 @@ +# Handgepflegte Spielregeln für Set 17 (Space Gods). +# Quellen: LoL-Wiki, Esports Tales (Stand Patch 17.7). +# Werte mit TODO gegen Patch-Notes / In-Game prüfen. + +set = 17 + +[shop] +# Odds pro Level: [1-cost, 2-cost, 3-cost, 4-cost, 5-cost], Summe = 100. +# TODO: Quellen widersprechen sich für Level 5-9 (Patch 17.1b änderte Odds). +odds = [ + [100, 0, 0, 0, 0], # Level 1 + [100, 0, 0, 0, 0], # Level 2 + [75, 25, 0, 0, 0], # Level 3 + [55, 30, 15, 0, 0], # Level 4 + [45, 33, 20, 2, 0], # Level 5 + [30, 40, 25, 5, 0], # Level 6 + [19, 30, 40, 10, 1], # Level 7 + [17, 24, 32, 24, 3], # Level 8 + [15, 18, 25, 30, 12],# Level 9 + [5, 10, 20, 40, 25], # Level 10 +] +reroll_cost = 2 +slots = 5 + +[pool] +# Kopien pro Unit im geteilten Pool, nach Kosten. +sizes = [30, 25, 18, 10, 9] +copies_for_2star = 3 +copies_for_3star = 9 + +[xp] +# XP zum nächsten Level, Index 0 = Level 1 -> 2. +to_next = [2, 2, 6, 10, 20, 36, 60, 68, 68] +passive_per_round = 2 +buy_amount = 4 +buy_cost = 4 +max_level = 10 + +[gold] +base_income = 5 +# Runden 1-2 bis 2-1 reduziert: +early_income = { "1-2" = 2, "1-3" = 2, "1-4" = 3, "2-1" = 4 } +interest_per_10 = 1 +interest_cap = 5 +win_bonus = 1 +# Streak-Gold: [min_streak, gold] +streak_gold = [[3, 1], [5, 2], [6, 3]] +starting_gold = 0 + +[damage] +# Spielerschaden = stage_base[stage] + per_surviving_unit * überlebende Units. +# TODO: gegen aktuelle Werte prüfen. +stage_base = [0, 0, 1, 2, 8, 15, 30, 40] # Index 0 = Stage 1 +per_surviving_unit = 2 +player_hp = 100 + +[rounds] +# Stage 1: Opening-Carousel + 3 PvE-Runden. +stage1 = ["carousel", "pve", "pve", "pve"] +# Stages 2+: 7 Runden, Carousel bei x-4, PvE bei x-7. +stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"] +augment_rounds = ["2-1", "3-2", "4-2"] + +[loot] +# Vereinfachtes Loot: PvE-Drops als [components, gold]. +stage1_pve = [1, 1] # pro Stage-1-PvE-Runde +stage_n_pve = [1, 2] # pro x-7-PvE-Runde +carousel = [1, 0] # Carousel gibt 1 Item-Komponente (+ Unit) + +[stars] +hp_multiplier = 1.8 +ad_multiplier = 1.5 + +[combat] +# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)); tau wird in M6 kalibriert. +tau = 0.15 +variance = 0.08