From 8a474e37a465b77ef08e2458f129ca0c7770daba Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 00:35:30 +0200 Subject: [PATCH] M7+M8: sim engine, combat, archetype bots, autoplay policies Co-Authored-By: Claude Fable 5 --- backend/tests/test_game.py | 81 +++++++++++ backend/tests/test_sim.py | 96 +++++++++++++ backend/tft/cli.py | 18 +++ backend/tft/sim/__init__.py | 0 backend/tft/sim/autoplay.py | 119 +++++++++++++++ backend/tft/sim/bots.py | 73 ++++++++++ backend/tft/sim/combat.py | 27 ++++ backend/tft/sim/economy.py | 40 ++++++ backend/tft/sim/game.py | 278 ++++++++++++++++++++++++++++++++++++ backend/tft/sim/pool.py | 25 ++++ backend/tft/sim/rounds.py | 16 +++ backend/tft/sim/shop.py | 19 +++ 12 files changed, 792 insertions(+) create mode 100644 backend/tests/test_game.py create mode 100644 backend/tests/test_sim.py create mode 100644 backend/tft/sim/__init__.py create mode 100644 backend/tft/sim/autoplay.py create mode 100644 backend/tft/sim/bots.py create mode 100644 backend/tft/sim/combat.py create mode 100644 backend/tft/sim/economy.py create mode 100644 backend/tft/sim/game.py create mode 100644 backend/tft/sim/pool.py create mode 100644 backend/tft/sim/rounds.py create mode 100644 backend/tft/sim/shop.py diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py new file mode 100644 index 0000000..06289ab --- /dev/null +++ b/backend/tests/test_game.py @@ -0,0 +1,81 @@ +import pytest + +from tft.constants.loader import load_constants +from tft.model import artifact as artifact_mod +from tft.sim.autoplay import play_afk, play_econ +from tft.sim.game import Game, InvalidAction +from tests.test_sim import make_static + + +@pytest.fixture(scope="module") +def art(): + static = make_static() + static["meta"]["patch"] = "test" + return artifact_mod.build(static) + + +@pytest.fixture(scope="module") +def cfg(): + return load_constants(17) + + +def test_afk_game_terminates_last(art, cfg): + placements = [play_afk(Game(art, cfg, seed=s)) for s in range(20)] + assert all(1 <= p <= 8 for p in placements) + assert sum(placements) / len(placements) > 6.5 + + +def test_econ_beats_afk(art, cfg): + afk = [play_afk(Game(art, cfg, seed=s)) for s in range(30)] + econ = [play_econ(Game(art, cfg, seed=1000 + s)) for s in range(30)] + assert sum(econ) / len(econ) < sum(afk) / len(afk) + + +def test_same_seed_same_outcome(art, cfg): + assert play_econ(Game(art, cfg, seed=7)) == play_econ(Game(art, cfg, seed=7)) + + +def test_buy_and_merge(art, cfg): + game = Game(art, cfg, seed=1) + game.gold = 100 + api = next(a for a in game.shop if a) + unit = {"api_name": api, "stars": 1, "items": []} + game.bench = [dict(unit), dict(unit)] + game.pool.take(api, 2) + game.shop = [api] * 5 + game.buy(0) + assert len(game.bench) == 1 + assert game.bench[0]["stars"] == 2 + + +def test_sell_refunds_pool_and_gold(art, cfg): + game = Game(art, cfg, seed=1) + game.gold = 100 + game.shop[0] = "U1_0" + before_pool = game.pool.available("U1_0") + game.buy(0) + gold_after_buy = game.gold + game.sell("bench", len(game.bench) - 1) + assert game.gold == gold_after_buy + 1 + assert game.pool.available("U1_0") == before_pool + + +def test_board_cap_is_level(art, cfg): + game = Game(art, cfg, seed=1) + game.bench = [{"api_name": "U1_0", "stars": 1, "items": []} for _ in range(3)] + game.level = 2 + game.move("bench", 0) + game.move("bench", 0) + with pytest.raises(InvalidAction, match="board full"): + game.move("bench", 0) + + +def test_invalid_actions_raise(art, cfg): + game = Game(art, cfg, seed=1) + game.gold = 0 + with pytest.raises(InvalidAction): + game.reroll() + with pytest.raises(InvalidAction): + game.buy_xp() + with pytest.raises(InvalidAction): + game.sell("bench", 0) diff --git a/backend/tests/test_sim.py b/backend/tests/test_sim.py new file mode 100644 index 0000000..8f84b84 --- /dev/null +++ b/backend/tests/test_sim.py @@ -0,0 +1,96 @@ +import random + +import pytest + +from tft.constants.loader import load_constants +from tft.sim import economy +from tft.sim.pool import Pool +from tft.sim.rounds import schedule +from tft.sim.shop import roll + + +def make_static(per_tier: int = 13) -> dict: + units = {} + for cost in range(1, 6): + for i in range(per_tier): + units[f"U{cost}_{i}"] = { + "cost": cost, + "traits": ["T1"], + "role": "Tank", + "stats": {"hp": 600 + 100 * cost, "armor": 40, "magicResist": 40, + "damage": 55, "attackSpeed": 0.7, "mana": 60, + "initialMana": 0, "range": 1}, + } + return { + "meta": {"set": 17}, + "units": units, + "traits": {"T1": {"breakpoints": [{"min_units": 2, "style": 1}]}}, + "items": {f"TFT_Item_{i}": {} for i in range(10)}, + "augments": {f"TFT17_Augment_{i}": {} for i in range(8)}, + } + + +@pytest.fixture(scope="module") +def cfg(): + return load_constants(17) + + +@pytest.fixture +def static(): + return make_static() + + +def test_pool_take_put_back(static, cfg): + pool = Pool(static["units"], cfg["pool"]["sizes"]) + assert pool.available("U1_0") == 30 + pool.take("U1_0", 3) + assert pool.available("U1_0") == 27 + pool.put_back("U1_0", 3) + assert pool.available("U1_0") == 30 + with pytest.raises(ValueError): + pool.take("U5_0", 10) + + +def test_shop_odds_empirical(static, cfg): + pool = Pool(static["units"], cfg["pool"]["sizes"]) + rng = random.Random(42) + counts = [0] * 5 + n = 10_000 + for _ in range(n // cfg["shop"]["slots"]): + for api in roll(pool, 8, cfg, rng): + counts[static["units"][api]["cost"] - 1] += 1 + expected = cfg["shop"]["odds"][7] + for tier in range(5): + assert counts[tier] / n == pytest.approx(expected[tier] / 100, abs=0.02) + + +def test_interest_cap(cfg): + assert economy.interest(0, cfg) == 0 + assert economy.interest(39, cfg) == 3 + assert economy.interest(80, cfg) == cfg["gold"]["interest_cap"] + + +def test_streak_gold(cfg): + assert economy.streak_gold(1, cfg) == 0 + assert economy.streak_gold(3, cfg) == 1 + assert economy.streak_gold(-5, cfg) == 2 + assert economy.streak_gold(9, cfg) == 3 + + +def test_apply_xp_levels_up(cfg): + level, xp = economy.apply_xp(1, 0, 4, cfg) + assert level == 3 # 1->2 costs 2, 2->3 costs 2 + assert xp == 0 + level, _ = economy.apply_xp(9, 0, 1000, cfg) + assert level == cfg["xp"]["max_level"] + + +def test_schedule(cfg): + rounds = schedule(cfg) + labels = [r["label"] for r in rounds] + assert labels[:4] == ["1-1", "1-2", "1-3", "1-4"] + assert rounds[0]["kind"] == "carousel" + aug = [r["label"] for r in rounds if r["augment"]] + assert aug == cfg["rounds"]["augment_rounds"] + stage2 = [r for r in rounds if r["stage"] == 2] + assert [r["kind"] for r in stage2] == cfg["rounds"]["stage_n"] diff --git a/backend/tft/cli.py b/backend/tft/cli.py index cf9e6f9..b352337 100644 --- a/backend/tft/cli.py +++ b/backend/tft/cli.py @@ -25,6 +25,10 @@ def main() -> None: sub.add_parser("build-artifact", help="build analysis.json from static data + endboards") sub.add_parser("calibrate", help="backtest score vs real placements (holdout)") + p_auto = sub.add_parser("autoplay", help="run scripted games headless") + p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ") + p_auto.add_argument("--games", type=int, default=200) + args = parser.parse_args() if args.command == "fetch-static": @@ -80,6 +84,20 @@ def main() -> None: print(f"holdout matches: {result['matches']}") print(f"mean spearman (score vs placement): {result['mean_spearman']:.3f}") + elif args.command == "autoplay": + from tft.constants.loader import load_constants + from tft.model import artifact as artifact_mod + from tft.sim.autoplay import run + + set_number = current_set() + result = run( + artifact_mod.load(set_number), load_constants(set_number), + args.policy, args.games, + ) + print(f"{args.policy}: {result['games']} games, " + f"avg placement {result['avg_placement']:.2f}, " + f"top4 {result['top4_rate']:.0%}") + if __name__ == "__main__": main() diff --git a/backend/tft/sim/__init__.py b/backend/tft/sim/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tft/sim/autoplay.py b/backend/tft/sim/autoplay.py new file mode 100644 index 0000000..79fe122 --- /dev/null +++ b/backend/tft/sim/autoplay.py @@ -0,0 +1,119 @@ +"""Scripted policies playing full games headless — sanity check for the sim.""" + +from tft.sim.game import Game, InvalidAction + + +def unit_worth(game: Game, u: dict) -> float: + cost = game.artifact["static"]["units"][u["api_name"]]["cost"] + return cost * 3 ** (u["stars"] - 1) + + +def play_afk(game: Game) -> int: + while not game.over: + game.step() + return game.placement + + +def play_econ(game: Game) -> int: + """Buy merges + strong units, keep board full, level with surplus gold.""" + while not game.over: + if game.augment_offer: + game.pick_augment(0) + + for slot, api in enumerate(list(game.shop)): + if api is None: + continue + cost = game.artifact["static"]["units"][api]["cost"] + owned = sum(1 for u in game.board + game.bench + if u["api_name"] == api and u["stars"] == 1) + if game.gold >= cost and (owned >= 1 or len(game.bench) < 7): + try: + game.buy(slot) + except InvalidAction: + pass + + game.bench.sort(key=lambda u: -unit_worth(game, u)) + while len(game.board) < game.level and game.bench: + try: + game.move("bench", 0) + except InvalidAction: + break + + while game.items and any(len(u["items"]) < 3 for u in game.board): + target = min(range(len(game.board)), key=lambda i: len(game.board[i]["items"])) + try: + game.equip(0, target) + except InvalidAction: + break + + stage = game.round["stage"] + while stage >= 3 and game.gold >= 54 + game.cfg["xp"]["buy_cost"]: + try: + game.buy_xp() + except InvalidAction: + break + + # Rolldown ab Stage 4: Bench-Müll verkaufen, dann nach Upgrades suchen. + if stage >= 4: + _sell_junk(game) + while stage >= 4 and game.gold > 30: + _buy_upgrades(game) + try: + game.reroll() + except InvalidAction: + break + + _fill_board(game) + game.step() + return game.placement + + +def _sell_junk(game: Game) -> None: + """Sell 1-star bench units that have no merge partner anywhere.""" + for idx in range(len(game.bench) - 1, -1, -1): + u = game.bench[idx] + copies = sum(1 for o in game.board + game.bench + if o["api_name"] == u["api_name"] and o["stars"] == u["stars"]) + if u["stars"] == 1 and copies == 1 and not u["items"]: + game.sell("bench", idx) + + +def _buy_upgrades(game: Game) -> None: + for slot, api in enumerate(list(game.shop)): + if api is None: + continue + cost = game.artifact["static"]["units"][api]["cost"] + owned = sum(1 for u in game.board + game.bench + if u["api_name"] == api and u["stars"] < 3) + if game.gold >= cost and owned >= 1: + try: + game.buy(slot) + except InvalidAction: + return + + +def _fill_board(game: Game) -> None: + game.bench.sort(key=lambda u: -unit_worth(game, u)) + while len(game.board) < game.level and game.bench: + try: + game.move("bench", 0) + except InvalidAction: + break + for i, u in enumerate(game.board): + if game.bench and unit_worth(game, game.bench[0]) > unit_worth(game, u): + game.board[i], game.bench[0] = game.bench[0], game.board[i] + + +POLICIES = {"afk": play_afk, "econ": play_econ} + + +def run(artifact: dict, cfg: dict, policy: str, n: int, seed: int = 1) -> dict: + placements = [] + for i in range(n): + game = Game(artifact, cfg, seed=seed + i) + placements.append(POLICIES[policy](game)) + return { + "games": n, + "avg_placement": sum(placements) / n, + "top4_rate": sum(1 for p in placements if p <= 4) / n, + } diff --git a/backend/tft/sim/bots.py b/backend/tft/sim/bots.py new file mode 100644 index 0000000..bebbbc9 --- /dev/null +++ b/backend/tft/sim/bots.py @@ -0,0 +1,73 @@ +"""Opponent bots: archetype level/tempo curves, synthesized boards from the shared pool.""" + +import random + +from tft.model.score import score_board +from tft.sim.pool import Pool + +ARCHETYPES = { + "fast8": { + "levels": {1: 3, 2: 4, 3: 5, 4: 7, 5: 8, 6: 8, 7: 9, 8: 9, 9: 9}, + "tempo": {1: 1.0, 2: 0.95, 3: 0.9, 4: 0.88, 5: 1.02, 6: 1.08, 7: 1.08, 8: 1.08, 9: 1.08}, + }, + "reroll": { + "levels": {1: 3, 2: 4, 3: 5, 4: 6, 5: 7, 6: 7, 7: 8, 8: 8, 9: 8}, + "tempo": {1: 1.0, 2: 1.0, 3: 1.02, 4: 0.98, 5: 1.0, 6: 1.08, 7: 1.05, 8: 1.02, 9: 1.02}, + }, + "streaker": { + "levels": {1: 3, 2: 5, 3: 6, 4: 7, 5: 8, 6: 8, 7: 9, 8: 9, 9: 9}, + "tempo": {1: 1.08, 2: 1.1, 3: 1.05, 4: 1.0, 5: 0.95, 6: 0.95, 7: 0.95, 8: 0.95, 9: 0.95}, + }, +} + + +class Bot: + def __init__(self, name: str, archetype: str, hp: int): + self.name = name + self.archetype = archetype + self.hp = hp + self.board: list[dict] = [] + self.placement: int | None = None + + @property + def alive(self) -> bool: + return self.hp > 0 + + def level(self, stage: int) -> int: + levels = ARCHETYPES[self.archetype]["levels"] + return levels[min(stage, max(levels))] + + def tempo(self, stage: int) -> float: + tempo = ARCHETYPES[self.archetype]["tempo"] + return tempo[min(stage, max(tempo))] + + def refresh_board(self, stage: int, pool: Pool, artifact: dict, cfg: dict, + rng: random.Random) -> None: + for u in self.board: + pool.put_back(u["api_name"], 3 ** (u["stars"] - 1)) + self.board = [] + + level = self.level(stage) + odds = cfg["shop"]["odds"][min(level, len(cfg["shop"]["odds"])) - 1] + p_2star = min(0.08 * stage, 0.55) + items = list(artifact["static"]["items"]) + n_items = max(stage - 1, 0) + + for _ in range(level): + tier = rng.choices(range(1, 6), weights=odds)[0] + candidates = pool.units_of_cost(tier) + if not candidates: + continue + api = rng.choices(candidates, weights=[pool.available(a) for a in candidates])[0] + stars = 2 if (rng.random() < p_2star and pool.available(api) >= 3) else 1 + pool.take(api, 3 ** (stars - 1)) + self.board.append({"api_name": api, "stars": stars, "items": []}) + + for _ in range(n_items): + holders = [u for u in self.board if len(u["items"]) < 3] + if not holders or not items: + break + rng.choice(holders)["items"].append(rng.choice(items)) + + def score(self, stage: int, artifact: dict) -> float: + return score_board(self.board, [], artifact) * self.tempo(stage) diff --git a/backend/tft/sim/combat.py b/backend/tft/sim/combat.py new file mode 100644 index 0000000..5c1ae54 --- /dev/null +++ b/backend/tft/sim/combat.py @@ -0,0 +1,27 @@ +"""Combat = score comparison. Relative score difference -> win probability -> damage.""" + +import math +import random + + +def win_probability(score_a: float, score_b: float, cfg: dict) -> float: + mean = (score_a + score_b) / 2 + if mean <= 0: + return 0.5 + d = (score_a - score_b) / mean + return 1 / (1 + math.exp(-d / cfg["combat"]["tau"])) + + +def resolve(score_a: float, score_b: float, cfg: dict, rng: random.Random) -> bool: + """True if A wins. Adds noise on top of the probability.""" + noise = rng.gauss(0, cfg["combat"]["variance"]) + return rng.random() < min(max(win_probability(score_a, score_b, cfg) + noise, 0.02), 0.98) + + +def damage(stage: int, winner_score: float, loser_score: float, winner_units: int, cfg: dict) -> int: + d = cfg["damage"] + base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)] + mean = (winner_score + loser_score) / 2 or 1 + margin = abs(winner_score - loser_score) / mean + surviving = max(1, round(winner_units * min(margin, 1.0))) + return base + d["per_surviving_unit"] * surviving diff --git a/backend/tft/sim/economy.py b/backend/tft/sim/economy.py new file mode 100644 index 0000000..069e33d --- /dev/null +++ b/backend/tft/sim/economy.py @@ -0,0 +1,40 @@ +"""Pure gold/XP math. State lives in game.py.""" + + +def interest(gold: int, cfg: dict) -> int: + g = cfg["gold"] + return min(gold // 10 * g["interest_per_10"], g["interest_cap"]) + + +def streak_gold(streak: int, cfg: dict) -> int: + bonus = 0 + for min_streak, gold in cfg["gold"]["streak_gold"]: + if abs(streak) >= min_streak: + bonus = gold + return bonus + + +def round_income(gold: int, streak: int, won: bool, round_label: str, cfg: dict) -> int: + g = cfg["gold"] + base = g["early_income"].get(round_label, g["base_income"]) + income = base + interest(gold, cfg) + streak_gold(streak, cfg) + if won: + income += g["win_bonus"] + return income + + +def xp_to_next(level: int, cfg: dict) -> int | None: + to_next = cfg["xp"]["to_next"] + if level >= cfg["xp"]["max_level"]: + return None + return to_next[level - 1] + + +def apply_xp(level: int, xp: int, gained: int, cfg: dict) -> tuple[int, int]: + xp += gained + while (need := xp_to_next(level, cfg)) is not None and xp >= need: + xp -= need + level += 1 + if level >= cfg["xp"]["max_level"]: + xp = 0 + return level, xp diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py new file mode 100644 index 0000000..738d80b --- /dev/null +++ b/backend/tft/sim/game.py @@ -0,0 +1,278 @@ +"""Game state and action dispatch: one human player vs 7 archetype bots.""" + +import random + +from tft.model.score import score_board +from tft.sim import combat, economy +from tft.sim.bots import ARCHETYPES, Bot +from tft.sim.pool import Pool +from tft.sim.rounds import schedule +from tft.sim.shop import roll + +BENCH_SIZE = 9 +MAX_ITEMS_PER_UNIT = 3 + + +class InvalidAction(Exception): + pass + + +class Game: + def __init__(self, artifact: dict, cfg: dict, seed: int | None = None): + self.artifact = artifact + self.cfg = cfg + self.rng = random.Random(seed) + self.pool = Pool(artifact["static"]["units"], cfg["pool"]["sizes"]) + self.rounds = schedule(cfg) + self.idx = 0 + + self.hp = cfg["damage"]["player_hp"] + self.gold = cfg["gold"]["starting_gold"] + self.level = 1 + self.xp = 0 + self.board: list[dict] = [] + self.bench: list[dict] = [] + self.items: list[str] = [] + self.augments: list[str] = [] + self.streak = 0 + self.shop: list[str | None] = [] + self.augment_offer: list[str] = [] + self.placement: int | None = None + self.log: list[str] = [] + + names = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"] + archetypes = list(ARCHETYPES) + self.bots = [ + Bot(names[i], archetypes[i % len(archetypes)], self.hp) for i in range(7) + ] + self._refresh_bots() + self._reroll_shop() + self._maybe_offer_augment() + + # ---- helpers ---- + + @property + def round(self) -> dict: + return self.rounds[self.idx] + + @property + def alive(self) -> bool: + return self.hp > 0 and self.placement != 1 + + @property + def over(self) -> bool: + return self.placement is not None + + def player_score(self) -> float: + return score_board(self.board, self.augments, self.artifact) + + def _reroll_shop(self) -> None: + self.shop = roll(self.pool, self.level, self.cfg, self.rng) + + def _refresh_bots(self) -> None: + for bot in self.bots: + if bot.alive: + bot.refresh_board(self.round["stage"], self.pool, self.artifact, + self.cfg, self.rng) + + def _maybe_offer_augment(self) -> None: + if self.round.get("augment") and not self.augment_offer: + augments = [a for a in self.artifact["static"]["augments"] + if a not in self.augments] + if augments: + self.augment_offer = self.rng.sample(augments, min(3, len(augments))) + + def _grant_loot(self, components: int, gold: int) -> None: + items = list(self.artifact["static"]["items"]) + for _ in range(components): + self.items.append(self.rng.choice(items)) + self.gold += gold + + def _merge(self, api_name: str, stars: int) -> None: + while True: + copies = [u for u in self.board + self.bench + if u["api_name"] == api_name and u["stars"] == stars] + if len(copies) < 3: + return + keep, *rest = copies + keep["stars"] += 1 + for u in rest: + keep["items"].extend(u["items"]) + (self.board if u in self.board else self.bench).remove(u) + keep["items"] = keep["items"][:MAX_ITEMS_PER_UNIT] + stars += 1 + + # ---- player actions ---- + + def buy(self, slot: int) -> None: + if not (0 <= slot < len(self.shop)) or self.shop[slot] is None: + raise InvalidAction("empty shop slot") + api = self.shop[slot] + cost = self.artifact["static"]["units"][api]["cost"] + if self.gold < cost: + raise InvalidAction("not enough gold") + if len(self.bench) >= BENCH_SIZE: + raise InvalidAction("bench full") + if self.pool.available(api) < 1: + raise InvalidAction("unit not available") + self.pool.take(api) + self.gold -= cost + self.shop[slot] = None + self.bench.append({"api_name": api, "stars": 1, "items": []}) + self._merge(api, 1) + + def sell(self, where: str, idx: int) -> None: + units = self.board if where == "board" else self.bench + if not (0 <= idx < len(units)): + raise InvalidAction("no unit there") + u = units.pop(idx) + copies = 3 ** (u["stars"] - 1) + self.pool.put_back(u["api_name"], copies) + self.gold += self.artifact["static"]["units"][u["api_name"]]["cost"] * copies + self.items.extend(u["items"]) + + def reroll(self) -> None: + cost = self.cfg["shop"]["reroll_cost"] + if self.gold < cost: + raise InvalidAction("not enough gold") + self.gold -= cost + self._reroll_shop() + + def buy_xp(self) -> None: + xp = self.cfg["xp"] + if self.level >= xp["max_level"]: + raise InvalidAction("max level") + if self.gold < xp["buy_cost"]: + raise InvalidAction("not enough gold") + self.gold -= xp["buy_cost"] + self.level, self.xp = economy.apply_xp(self.level, self.xp, xp["buy_amount"], self.cfg) + + def move(self, where: str, idx: int) -> None: + src, dst = (self.bench, self.board) if where == "bench" else (self.board, self.bench) + if not (0 <= idx < len(src)): + raise InvalidAction("no unit there") + if dst is self.board and len(self.board) >= self.level: + raise InvalidAction("board full for your level") + if dst is self.bench and len(self.bench) >= BENCH_SIZE: + raise InvalidAction("bench full") + dst.append(src.pop(idx)) + + def equip(self, item_idx: int, board_idx: int) -> None: + if not (0 <= item_idx < len(self.items)): + raise InvalidAction("no such item") + if not (0 <= board_idx < len(self.board)): + raise InvalidAction("no such unit") + if len(self.board[board_idx]["items"]) >= MAX_ITEMS_PER_UNIT: + raise InvalidAction("unit has 3 items") + self.board[board_idx]["items"].append(self.items.pop(item_idx)) + + def pick_augment(self, choice: int) -> None: + if not self.augment_offer: + raise InvalidAction("no augment offer") + if not (0 <= choice < len(self.augment_offer)): + raise InvalidAction("invalid choice") + self.augments.append(self.augment_offer[choice]) + self.augment_offer = [] + + # ---- round resolution ---- + + def step(self) -> None: + """Resolve the current round and advance to the next planning phase.""" + if self.over: + raise InvalidAction("game over") + if self.augment_offer: + self.pick_augment(self.rng.randrange(len(self.augment_offer))) + + rnd = self.round + won = self._resolve(rnd) + self._bot_fights(rnd) + self._check_eliminations() + if self.over: + return + + self.streak = self.streak + 1 if won else min(self.streak, 0) - 1 + self.gold += economy.round_income(self.gold, self.streak, won, rnd["label"], self.cfg) + self.level, self.xp = economy.apply_xp( + self.level, self.xp, self.cfg["xp"]["passive_per_round"], self.cfg + ) + + if self.idx + 1 >= len(self.rounds): + self._finish_by_hp() + return + prev_stage = rnd["stage"] + self.idx += 1 + if self.round["stage"] != prev_stage: + self._refresh_bots() + self._reroll_shop() + self._maybe_offer_augment() + + def _resolve(self, rnd: dict) -> bool: + loot = self.cfg["loot"] + if rnd["kind"] == "pve": + key = "stage1_pve" if rnd["stage"] == 1 else "stage_n_pve" + self._grant_loot(*loot[key]) + self.log.append(f"{rnd['label']}: PvE gewonnen") + return True + if rnd["kind"] == "carousel": + self._grant_loot(*loot["carousel"]) + candidates = [a for c in (1, 2, 3) for a in self.pool.units_of_cost(c)] + if candidates and len(self.bench) < BENCH_SIZE: + api = self.rng.choice(candidates) + self.pool.take(api) + self.bench.append({"api_name": api, "stars": 1, "items": []}) + self._merge(api, 1) + self.log.append(f"{rnd['label']}: Carousel") + return True + + opponents = [b for b in self.bots if b.alive] + if not opponents: + self.placement = 1 + return True + bot = self.rng.choice(opponents) + my, theirs = self.player_score(), bot.score(rnd["stage"], self.artifact) + won = combat.resolve(my, theirs, self.cfg, self.rng) + if won: + dmg = combat.damage(rnd["stage"], my, theirs, len(self.board), self.cfg) + bot.hp -= dmg + self.log.append(f"{rnd['label']}: Sieg vs {bot.name} ({dmg} dmg)") + else: + dmg = combat.damage(rnd["stage"], theirs, my, len(bot.board), self.cfg) + self.hp -= dmg + self.log.append(f"{rnd['label']}: Niederlage vs {bot.name} (-{dmg} HP)") + return won + + def _bot_fights(self, rnd: dict) -> None: + if rnd["kind"] != "pvp": + return + others = [b for b in self.bots if b.alive] + self.rng.shuffle(others) + for a, b in zip(others[::2], others[1::2]): + sa, sb = a.score(rnd["stage"], self.artifact), b.score(rnd["stage"], self.artifact) + if combat.resolve(sa, sb, self.cfg, self.rng): + b.hp -= combat.damage(rnd["stage"], sa, sb, len(a.board), self.cfg) + else: + a.hp -= combat.damage(rnd["stage"], sb, sa, len(b.board), self.cfg) + + def _check_eliminations(self) -> None: + alive_bots = [b for b in self.bots if b.alive] + for bot in self.bots: + if not bot.alive and bot.placement is None: + bot.placement = len(alive_bots) + (2 if self.hp > 0 else 1) + for u in bot.board: + self.pool.put_back(u["api_name"], 3 ** (u["stars"] - 1)) + bot.board = [] + if self.hp <= 0 and self.placement is None: + self.placement = len(alive_bots) + 1 + elif not alive_bots and self.placement is None: + self.placement = 1 + + def _finish_by_hp(self) -> None: + standings = sorted( + [("player", self.hp)] + [(b.name, b.hp) for b in self.bots if b.alive], + key=lambda x: -x[1], + ) + for rank, (name, _) in enumerate(standings, start=1): + if name == "player": + self.placement = rank + else: + next(b for b in self.bots if b.name == name).placement = rank diff --git a/backend/tft/sim/pool.py b/backend/tft/sim/pool.py new file mode 100644 index 0000000..4e6cdf8 --- /dev/null +++ b/backend/tft/sim/pool.py @@ -0,0 +1,25 @@ +"""Shared unit pool: finite copies per unit, drawn by everyone.""" + + +class Pool: + def __init__(self, static_units: dict, sizes: list[int]): + self.copies = { + api: sizes[u["cost"] - 1] + for api, u in static_units.items() + if 1 <= u["cost"] <= len(sizes) + } + self.cost_of = {api: u["cost"] for api, u in static_units.items()} + + def available(self, api_name: str) -> int: + return self.copies.get(api_name, 0) + + def units_of_cost(self, cost: int) -> list[str]: + return [a for a, c in self.cost_of.items() if c == cost and self.copies[a] > 0] + + def take(self, api_name: str, n: int = 1) -> None: + if self.copies[api_name] < n: + raise ValueError(f"pool exhausted for {api_name}") + self.copies[api_name] -= n + + def put_back(self, api_name: str, n: int = 1) -> None: + self.copies[api_name] += n diff --git a/backend/tft/sim/rounds.py b/backend/tft/sim/rounds.py new file mode 100644 index 0000000..d678827 --- /dev/null +++ b/backend/tft/sim/rounds.py @@ -0,0 +1,16 @@ +"""Stage/round schedule: labels, types, augment rounds.""" + +MAX_STAGE = 9 + + +def schedule(cfg: dict) -> list[dict]: + r = cfg["rounds"] + rounds = [] + for i, kind in enumerate(r["stage1"], start=1): + rounds.append({"label": f"1-{i}", "stage": 1, "kind": kind}) + for stage in range(2, MAX_STAGE + 1): + for i, kind in enumerate(r["stage_n"], start=1): + rounds.append({"label": f"{stage}-{i}", "stage": stage, "kind": kind}) + for rnd in rounds: + rnd["augment"] = rnd["label"] in r["augment_rounds"] + return rounds diff --git a/backend/tft/sim/shop.py b/backend/tft/sim/shop.py new file mode 100644 index 0000000..b123a4a --- /dev/null +++ b/backend/tft/sim/shop.py @@ -0,0 +1,19 @@ +"""Shop rolls: tier by level odds, unit weighted by remaining pool copies.""" + +import random + +from tft.sim.pool import Pool + + +def roll(pool: Pool, level: int, cfg: dict, rng: random.Random) -> list[str | None]: + odds = cfg["shop"]["odds"][level - 1] + slots = [] + for _ in range(cfg["shop"]["slots"]): + tier = rng.choices(range(1, 6), weights=odds)[0] + candidates = pool.units_of_cost(tier) + if not candidates: + slots.append(None) + continue + weights = [pool.available(a) for a in candidates] + slots.append(rng.choices(candidates, weights=weights)[0]) + return slots