diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py index 9d36014..a91cacb 100644 --- a/backend/tests/test_game.py +++ b/backend/tests/test_game.py @@ -103,3 +103,84 @@ def test_carousel_grants_unit_and_loot(art, cfg): game = Game(art, cfg, seed=5) assert len(game.bench) == 1 # Unit vom 1-1-Carousel assert len(game.items) == 1 # Item-Komponente vom Carousel + + +def _pool_total(game, api): + n = game.pool.available(api) + for p in game.players: + for u in p.board + p.bench: + if u["api_name"] == api: + n += 3 ** (u["stars"] - 1) + return n + + +def test_pool_conservation(art, cfg): + for seed in (1, 2, 3): + game = Game(art, cfg, seed=seed) + initial = {api: cfg["pool"]["sizes"][art["static"]["units"][api]["cost"] - 1] + for api in art["static"]["units"]} + while not game.over: + game.step() + for api, total in initial.items(): + assert _pool_total(game, api) == total, f"pool leak: {api}" + + +def test_bots_start_with_one_unit(art, cfg): + game = Game(art, cfg, seed=7) + for b in game.bots: + assert len(b.board) + len(b.bench) == 1 # nur die Carousel-Unit + assert all(u["stars"] == 1 for u in b.board + b.bench) + + +def test_streak_resets_on_win(art, cfg): + game = Game(art, cfg, seed=1) + game.player.streak = -3 + # direkter Formel-Test wie in step(): + won = True + streak = max(game.player.streak, 0) + 1 if won else 0 + assert streak == 1 + + +def test_pairing(): + import random + from tft.sim.game import pair_players + from tft.sim.player import PlayerState + + def mk(n): + return [PlayerState(name=f"P{i}", archetype="fast8", hp=100, gold=0, level=2) + for i in range(n)] + + rng = random.Random(1) + for n in (8, 5, 3, 2): + players = mk(n) + pairs, odd, ghost_src = pair_players(players, rng) + paired = {p.name for a, b in pairs for p in (a, b)} + if odd: + assert odd.name not in paired + assert ghost_src is not odd + assert len(paired) + 1 == n + else: + assert len(paired) == n + + # kein Wiederholungsgegner bei >2 + players = mk(8) + rng = random.Random(2) + for _ in range(10): + pairs, _, _ = pair_players(players, rng) + for a, b in pairs: + assert a.last_opponent == b.name + + +def test_ghost_source_takes_no_damage(art, cfg): + game = Game(art, cfg, seed=3) + game.bots[0].hp = 0 + game.bots[0].placement = 8 + game.bots[0].board, game.bots[0].bench = [], [] + # 7 Lebende -> jede PvP-Runde hat einen Ghost-Kampf; HP-Summe der + # Ghost-Quelle darf nur durch ihren echten Kampf sinken (kein Doppelschaden). + hp_before = {p.name: p.hp for p in game.players} + while game.round["kind"] != "pvp": + game.step() + game.step() + drops = sum(1 for p in game.players if p.hp < hp_before[p.name]) + assert drops <= 4 # max. 3 Paar-Verlierer + 1 Ghost-Verlierer diff --git a/backend/tft/api/app.py b/backend/tft/api/app.py index a766aee..1a6669a 100644 --- a/backend/tft/api/app.py +++ b/backend/tft/api/app.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from tft import paths from tft.model.score import active_trait_tiers from tft.sim import economy +from tft.sim import player as sim_player from tft.sim.game import Game, InvalidAction app = FastAPI(title="tft-sim") @@ -191,8 +192,10 @@ def serialize(game: Game) -> dict: { "name": b.name, "archetype": b.archetype, "hp": max(b.hp, 0), "alive": b.alive, "placement": b.placement, - "level": b.level(stage) if b.alive else None, - "score": round(b.score(stage, game.artifact), 1) if b.alive else None, + "level": b.level if b.alive else None, + "gold": b.gold if b.alive else None, + "streak": b.streak if b.alive else None, + "score": round(sim_player.score(b, game.artifact), 1) if b.alive else None, "board": [unit_view(game, u) for u in b.board], } for b in game.bots diff --git a/backend/tft/sim/autoplay.py b/backend/tft/sim/autoplay.py index 79fe122..acf2c6c 100644 --- a/backend/tft/sim/autoplay.py +++ b/backend/tft/sim/autoplay.py @@ -1,11 +1,7 @@ """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) +from tft.sim import policy +from tft.sim.game import Game def play_afk(game: Game) -> int: @@ -15,103 +11,22 @@ def play_afk(game: Game) -> int: def play_econ(game: Game) -> int: - """Buy merges + strong units, keep board full, level with surplus gold.""" + params = policy.ARCHETYPES["fast8"] 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) + policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng, + game.round, params) 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: +def run(artifact: dict, cfg: dict, policy_name: 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)) + placements.append(POLICIES[policy_name](game)) return { "games": n, "avg_placement": sum(placements) / n, diff --git a/backend/tft/sim/bots.py b/backend/tft/sim/bots.py deleted file mode 100644 index 8a701e4..0000000 --- a/backend/tft/sim/bots.py +++ /dev/null @@ -1,73 +0,0 @@ -"""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 = artifact.get("item_pool") or 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/game.py b/backend/tft/sim/game.py index ca606cd..1da18cf 100644 --- a/backend/tft/sim/game.py +++ b/backend/tft/sim/game.py @@ -1,20 +1,29 @@ -"""Game state and action dispatch: one human player vs 7 archetype bots.""" +"""Game orchestration: one human player + 7 policy bots under identical rules.""" 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 import combat, economy, player, policy +from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401 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 +BOT_NAMES = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"] -class InvalidAction(Exception): - pass +def pair_players(alive: list[PlayerState], rng: random.Random): + """PvP-Paarung: kein Wiederholungsgegner bei >2 Lebenden; ungerade -> Ghost.""" + order = list(alive) + pairs, odd = [], None + for _ in range(20): + rng.shuffle(order) + pairs = list(zip(order[::2], order[1::2])) + odd = order[-1] if len(order) % 2 else None + if len(alive) <= 2 or all(a.last_opponent != b.name for a, b in pairs): + break + for a, b in pairs: + a.last_opponent, b.last_opponent = b.name, a.name + ghost_src = rng.choice([p for p in alive if p is not odd]) if odd else None + return pairs, odd, ghost_src class Game: @@ -25,167 +34,90 @@ class Game: 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 = cfg["xp"].get("start_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.shop_locked = False - 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.player = player.new_player("Du", "human", cfg) + archetypes = list(policy.ARCHETYPES) self.bots = [ - Bot(names[i], archetypes[i % len(archetypes)], self.hp) for i in range(7) + player.new_player(BOT_NAMES[i], archetypes[i % len(archetypes)], cfg) + for i in range(7) ] - # 1-1-Carousel läuft automatisch ab: Unit + Loot, dann startet 1-2. + + # 1-1-Carousel läuft automatisch für alle ab, dann startet 1-2. if self.round["kind"] == "carousel": - self._resolve(self.round) + for p in self.players: + self._carousel(p) self.idx += 1 - self._refresh_bots() - self._reroll_shop() + for p in self.players: + player.refresh_shop(p, self.pool, cfg, self.rng) self._maybe_offer_augment() # ---- helpers ---- + @property + def players(self) -> list[PlayerState]: + return [self.player] + self.bots + @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 + return self.player.placement is not None def player_score(self) -> float: - return score_board(self.board, self.augments, self.artifact) + return player.score(self.player, self.artifact) - def _reroll_shop(self) -> None: - self.shop = roll(self.pool, self.level, self.cfg, self.rng) + def _alive(self) -> list[PlayerState]: + return [p for p in self.players if p.alive] - 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 _carousel(self, p: PlayerState) -> None: + player.grant_loot(p, *self.cfg["loot"]["carousel"], self.artifact, self.rng) + player.grab_carousel_unit(p, self.pool, self.rng) + if p is self.player: + self.log.append(f"{self.round['label']}: Carousel") 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))) + if not self.round.get("augment"): + return + available = [a for a in self.artifact["static"]["augments"] + if a not in self.player.augments] + if available and not self.player.augment_offer: + self.player.augment_offer = self.rng.sample(available, min(3, len(available))) + for b in self.bots: + if not b.alive: + continue + choices = [a for a in self.artifact["static"]["augments"] + if a not in b.augments] + if choices: + b.augments.append(self.rng.choice(choices)) - def _grant_loot(self, components: int, gold: int) -> None: - items = self.artifact.get("item_pool") or 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 ---- + # ---- human actions (delegation) ---- 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) + player.buy(self.player, slot, self.pool, self.artifact) 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) - cost = self.artifact["static"]["units"][u["api_name"]]["cost"] - refund = cost * copies - # Ab 2 Sternen und Kosten >= 2 gilt -1 Gold; 1-Coster immer voller Wert. - if cost > 1 and u["stars"] >= 2: - refund -= 1 - self.gold += refund - self.items.extend(u["items"]) + player.sell(self.player, where, idx, self.pool, self.artifact) 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() + player.reroll(self.player, self.pool, self.cfg, self.rng) 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) + player.buy_xp(self.player, 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)) + player.move(self.player, where, 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)) + player.equip(self.player, item_idx, board_idx) def toggle_lock(self) -> None: - self.shop_locked = not self.shop_locked + self.player.shop_locked = not self.player.shop_locked 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 = [] + player.pick_augment(self.player, choice) # ---- round resolution ---- @@ -193,121 +125,128 @@ class Game: """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))) - # Wie in TFT: freie Board-Plätze werden beim Kampfstart von der Bank aufgefüllt. - self.bench.sort( - key=lambda u: -self.artifact["static"]["units"][u["api_name"]]["cost"] - * 3 ** (u["stars"] - 1) - ) - while len(self.board) < self.level and self.bench: - self.board.append(self.bench.pop(0)) + if self.player.augment_offer: + self.pick_augment(self.rng.randrange(len(self.player.augment_offer))) rnd = self.round - won = self._resolve(rnd) - self._bot_fights(rnd) + for b in self.bots: + if b.alive: + policy.act(b, self.pool, self.artifact, self.cfg, self.rng, rnd, + policy.ARCHETYPES[b.archetype]) + for p in self._alive(): + player.fill_board(p, self.artifact) + + results = self._resolve(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 - ) + for p in self._alive(): + won = results.get(p.name, False) + p.streak = max(p.streak, 0) + 1 if won else min(p.streak, 0) - 1 + p.gold += economy.round_income(p.gold, p.streak, won, rnd["label"], self.cfg) + p.level, p.xp = economy.apply_xp( + p.level, p.xp, self.cfg["xp"]["passive_per_round"], self.cfg + ) self._advance() + def _resolve(self, rnd: dict) -> dict: + """name -> hat die Runde gewonnen.""" + if rnd["kind"] == "pve": + key = "stage1_pve" if rnd["stage"] == 1 else "stage_n_pve" + for p in self._alive(): + player.grant_loot(p, *self.cfg["loot"][key], self.artifact, self.rng) + self.log.append(f"{rnd['label']}: PvE gewonnen") + return {p.name: True for p in self._alive()} + + results = {} + pairs, odd, ghost_src = pair_players(self._alive(), self.rng) + for a, b in pairs: + a_wins = self._fight(rnd, a, b) + results[a.name] = a_wins + results[b.name] = not a_wins + if odd is not None: + # Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler. + s_odd = player.score(odd, self.artifact) + s_ghost = player.score(ghost_src, self.artifact) + won = combat.resolve(s_odd, s_ghost, self.cfg, self.rng) + results[odd.name] = won + if not won: + dmg = combat.damage(rnd["stage"], s_ghost, s_odd, + len(ghost_src.board), self.cfg) + odd.hp -= dmg + if odd is self.player: + self.log.append( + f"{rnd['label']}: Niederlage vs Ghost ({ghost_src.name}) (-{dmg} HP)") + elif odd is self.player: + self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})") + return results + + def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool: + sa = player.score(a, self.artifact) + sb = player.score(b, self.artifact) + a_wins = combat.resolve(sa, sb, self.cfg, self.rng) + winner, loser = (a, b) if a_wins else (b, a) + w_score, l_score = (sa, sb) if a_wins else (sb, sa) + dmg = combat.damage(rnd["stage"], w_score, l_score, len(winner.board), self.cfg) + loser.hp -= dmg + if a is self.player or b is self.player: + if winner is self.player: + self.log.append(f"{rnd['label']}: Sieg vs {loser.name} ({dmg} dmg)") + else: + self.log.append(f"{rnd['label']}: Niederlage vs {winner.name} (-{dmg} HP)") + return a_wins + + def _check_eliminations(self) -> None: + alive_count = len(self._alive()) + newly_dead = [p for p in self.players if not p.alive and p.placement is None] + for i, p in enumerate(newly_dead): + p.placement = alive_count + len(newly_dead) - i + for u in p.board + p.bench: + self.pool.put_back(u["api_name"], 3 ** (u["stars"] - 1)) + p.board, p.bench = [], [] + if p is not self.player: + self.log.append(f"{p.name} ausgeschieden (#{p.placement})") + if alive_count == 1 and self.player.alive and self.player.placement is None: + self.player.placement = 1 + def _advance(self) -> None: - """Zur nächsten Planungsrunde; Carousels laufen dabei automatisch ab.""" + """Zur nächsten Planungsrunde; Carousels laufen dabei automatisch für alle ab.""" while True: if self.idx + 1 >= len(self.rounds): self._finish_by_hp() return - prev_stage = self.round["stage"] self.idx += 1 - if self.round["stage"] != prev_stage: - self._refresh_bots() if self.round["kind"] != "carousel": break - self._resolve(self.round) - self.gold += economy.round_income( - self.gold, self.streak, False, self.round["label"], self.cfg - ) - self.level, self.xp = economy.apply_xp( - self.level, self.xp, self.cfg["xp"]["passive_per_round"], self.cfg - ) - if not self.shop_locked: - self._reroll_shop() + for p in self._alive(): + self._carousel(p) + p.gold += economy.round_income( + p.gold, p.streak, False, self.round["label"], self.cfg + ) + p.level, p.xp = economy.apply_xp( + p.level, p.xp, self.cfg["xp"]["passive_per_round"], self.cfg + ) + for p in self._alive(): + if p is self.player and p.shop_locked: + continue + player.refresh_shop(p, self.pool, self.cfg, self.rng) 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 + standings = sorted(self._alive(), key=lambda p: -p.hp) + for rank, p in enumerate(standings, start=1): + p.placement = rank + + +def _delegate(attr: str) -> property: + return property( + lambda self: getattr(self.player, attr), + lambda self, value: setattr(self.player, attr, value), + ) + + +for _attr in ("hp", "gold", "level", "xp", "board", "bench", "items", "augments", + "streak", "shop", "shop_locked", "augment_offer", "placement"): + setattr(Game, _attr, _delegate(_attr)) diff --git a/backend/tft/sim/player.py b/backend/tft/sim/player.py new file mode 100644 index 0000000..c9c2e58 --- /dev/null +++ b/backend/tft/sim/player.py @@ -0,0 +1,183 @@ +"""Per-player state and mechanics — identical rules for the human and every bot.""" + +import random +from dataclasses import dataclass, field + +from tft.model.score import score_board +from tft.sim import economy +from tft.sim.pool import Pool +from tft.sim.shop import roll + +BENCH_SIZE = 9 +MAX_ITEMS_PER_UNIT = 3 + + +class InvalidAction(Exception): + pass + + +@dataclass +class PlayerState: + name: str + archetype: str + hp: int + gold: int + level: int + xp: int = 0 + board: list = field(default_factory=list) + bench: list = field(default_factory=list) + items: list = field(default_factory=list) + augments: list = field(default_factory=list) + streak: int = 0 + shop: list = field(default_factory=list) + shop_locked: bool = False + augment_offer: list = field(default_factory=list) + placement: int | None = None + last_opponent: str | None = None + + @property + def alive(self) -> bool: + return self.hp > 0 + + +def new_player(name: str, archetype: str, cfg: dict) -> PlayerState: + return PlayerState( + name=name, + archetype=archetype, + hp=cfg["damage"]["player_hp"], + gold=cfg["gold"]["starting_gold"], + level=cfg["xp"].get("start_level", 1), + ) + + +def score(p: PlayerState, artifact: dict) -> float: + return score_board(p.board, p.augments, artifact) + + +def unit_worth(artifact: dict, u: dict) -> float: + return artifact["static"]["units"][u["api_name"]]["cost"] * 3 ** (u["stars"] - 1) + + +def refresh_shop(p: PlayerState, pool: Pool, cfg: dict, rng: random.Random) -> None: + p.shop = roll(pool, p.level, cfg, rng) + + +def merge(p: PlayerState, api_name: str, stars: int) -> None: + while True: + copies = [u for u in p.board + p.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"]) + (p.board if u in p.board else p.bench).remove(u) + keep["items"] = keep["items"][:MAX_ITEMS_PER_UNIT] + stars += 1 + + +def buy(p: PlayerState, slot: int, pool: Pool, artifact: dict) -> None: + if not (0 <= slot < len(p.shop)) or p.shop[slot] is None: + raise InvalidAction("empty shop slot") + api = p.shop[slot] + cost = artifact["static"]["units"][api]["cost"] + if p.gold < cost: + raise InvalidAction("not enough gold") + if len(p.bench) >= BENCH_SIZE: + raise InvalidAction("bench full") + if pool.available(api) < 1: + raise InvalidAction("unit not available") + pool.take(api) + p.gold -= cost + p.shop[slot] = None + p.bench.append({"api_name": api, "stars": 1, "items": []}) + merge(p, api, 1) + + +def sell(p: PlayerState, where: str, idx: int, pool: Pool, artifact: dict) -> None: + units = p.board if where == "board" else p.bench + if not (0 <= idx < len(units)): + raise InvalidAction("no unit there") + u = units.pop(idx) + copies = 3 ** (u["stars"] - 1) + pool.put_back(u["api_name"], copies) + cost = artifact["static"]["units"][u["api_name"]]["cost"] + refund = cost * copies + # Ab 2 Sternen und Kosten >= 2 gilt -1 Gold; 1-Coster immer voller Wert. + if cost > 1 and u["stars"] >= 2: + refund -= 1 + p.gold += refund + p.items.extend(u["items"]) + + +def reroll(p: PlayerState, pool: Pool, cfg: dict, rng: random.Random) -> None: + cost = cfg["shop"]["reroll_cost"] + if p.gold < cost: + raise InvalidAction("not enough gold") + p.gold -= cost + refresh_shop(p, pool, cfg, rng) + + +def buy_xp(p: PlayerState, cfg: dict) -> None: + xp = cfg["xp"] + if p.level >= xp["max_level"]: + raise InvalidAction("max level") + if p.gold < xp["buy_cost"]: + raise InvalidAction("not enough gold") + p.gold -= xp["buy_cost"] + p.level, p.xp = economy.apply_xp(p.level, p.xp, xp["buy_amount"], cfg) + + +def move(p: PlayerState, where: str, idx: int) -> None: + src, dst = (p.bench, p.board) if where == "bench" else (p.board, p.bench) + if not (0 <= idx < len(src)): + raise InvalidAction("no unit there") + if dst is p.board and len(p.board) >= p.level: + raise InvalidAction("board full for your level") + if dst is p.bench and len(p.bench) >= BENCH_SIZE: + raise InvalidAction("bench full") + dst.append(src.pop(idx)) + + +def equip(p: PlayerState, item_idx: int, board_idx: int) -> None: + if not (0 <= item_idx < len(p.items)): + raise InvalidAction("no such item") + if not (0 <= board_idx < len(p.board)): + raise InvalidAction("no such unit") + if len(p.board[board_idx]["items"]) >= MAX_ITEMS_PER_UNIT: + raise InvalidAction("unit has 3 items") + p.board[board_idx]["items"].append(p.items.pop(item_idx)) + + +def pick_augment(p: PlayerState, choice: int) -> None: + if not p.augment_offer: + raise InvalidAction("no augment offer") + if not (0 <= choice < len(p.augment_offer)): + raise InvalidAction("invalid choice") + p.augments.append(p.augment_offer[choice]) + p.augment_offer = [] + + +def grant_loot(p: PlayerState, components: int, gold: int, artifact: dict, + rng: random.Random) -> None: + items = artifact.get("item_pool") or list(artifact["static"]["items"]) + for _ in range(components): + p.items.append(rng.choice(items)) + p.gold += gold + + +def grab_carousel_unit(p: PlayerState, pool: Pool, rng: random.Random) -> None: + candidates = [a for c in (1, 2, 3) for a in pool.units_of_cost(c)] + if candidates and len(p.bench) < BENCH_SIZE: + api = rng.choice(candidates) + pool.take(api) + p.bench.append({"api_name": api, "stars": 1, "items": []}) + merge(p, api, 1) + + +def fill_board(p: PlayerState, artifact: dict) -> None: + """Wie in TFT: freie Board-Plätze werden beim Kampfstart von der Bank aufgefüllt.""" + p.bench.sort(key=lambda u: -unit_worth(artifact, u)) + while len(p.board) < p.level and p.bench: + p.board.append(p.bench.pop(0)) diff --git a/backend/tft/sim/policy.py b/backend/tft/sim/policy.py new file mode 100644 index 0000000..4dbc457 --- /dev/null +++ b/backend/tft/sim/policy.py @@ -0,0 +1,96 @@ +"""Scripted planning policies — drive the bots and the autoplay benchmarks.""" + +import random + +from tft.sim import player +from tft.sim.player import InvalidAction, PlayerState +from tft.sim.pool import Pool + +ARCHETYPES = { + # fast8: früh econ, ab Stage 3 leveln, Rolldown ab Level 8. + "fast8": {"level_cap": 10, "xp_stage": 3, "econ_floor": 54, + "roll_stage": 4, "roll_level": 8, "roll_floor": 30}, + # reroll: Level-Cap 7, rollt Überschuss über 50 für Upgrades. + "reroll": {"level_cap": 7, "xp_stage": 3, "econ_floor": 50, + "roll_stage": 3, "roll_level": 7, "roll_floor": 50}, + # streaker: gibt früh aus, levelt aggressiv, wenig Reserve. + "streaker": {"level_cap": 10, "xp_stage": 2, "econ_floor": 20, + "roll_stage": 4, "roll_level": 8, "roll_floor": 20}, +} + + +def act(p: PlayerState, pool: Pool, artifact: dict, cfg: dict, rng: random.Random, + rnd: dict, params: dict) -> None: + """Eine Planungsrunde für einen Spieler.""" + if p.augment_offer: + player.pick_augment(p, rng.randrange(len(p.augment_offer))) + + _buy_from_shop(p, pool, artifact) + + stage = rnd["stage"] + while (stage >= params["xp_stage"] and p.level < params["level_cap"] + and p.gold >= params["econ_floor"] + cfg["xp"]["buy_cost"]): + try: + player.buy_xp(p, cfg) + except InvalidAction: + break + + at_target = p.level >= params["roll_level"] or p.level >= params["level_cap"] + if stage >= params["roll_stage"] and at_target: + _sell_junk(p, pool, artifact) + while p.gold > params["roll_floor"]: + _buy_upgrades(p, pool, artifact) + try: + player.reroll(p, pool, cfg, rng) + except InvalidAction: + break + + _equip_items(p) + player.fill_board(p, artifact) + + +def _buy_from_shop(p: PlayerState, pool: Pool, artifact: dict) -> None: + for slot, api in enumerate(list(p.shop)): + if api is None: + continue + cost = artifact["static"]["units"][api]["cost"] + owned = sum(1 for u in p.board + p.bench + if u["api_name"] == api and u["stars"] == 1) + if p.gold >= cost and (owned >= 1 or len(p.bench) < 7): + try: + player.buy(p, slot, pool, artifact) + except InvalidAction: + pass + + +def _buy_upgrades(p: PlayerState, pool: Pool, artifact: dict) -> None: + for slot, api in enumerate(list(p.shop)): + if api is None: + continue + cost = artifact["static"]["units"][api]["cost"] + owned = sum(1 for u in p.board + p.bench + if u["api_name"] == api and u["stars"] < 3) + if p.gold >= cost and owned >= 1: + try: + player.buy(p, slot, pool, artifact) + except InvalidAction: + return + + +def _sell_junk(p: PlayerState, pool: Pool, artifact: dict) -> None: + """Sell 1-star bench units that have no merge partner anywhere.""" + for idx in range(len(p.bench) - 1, -1, -1): + u = p.bench[idx] + copies = sum(1 for o in p.board + p.bench + if o["api_name"] == u["api_name"] and o["stars"] == u["stars"]) + if u["stars"] == 1 and copies == 1 and not u["items"]: + player.sell(p, "bench", idx, pool, artifact) + + +def _equip_items(p: PlayerState) -> None: + while p.items and any(len(u["items"]) < 3 for u in p.board): + target = min(range(len(p.board)), key=lambda i: len(p.board[i]["items"])) + try: + player.equip(p, 0, target) + except InvalidAction: + break diff --git a/backend/tft/staticdata/parse.py b/backend/tft/staticdata/parse.py index 0aee4ec..aecc8c8 100644 --- a/backend/tft/staticdata/parse.py +++ b/backend/tft/staticdata/parse.py @@ -29,8 +29,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict: units = {} for c in set_data["champions"]: - if not c["traits"]: - continue # PvE monsters, summons + if not c["traits"] or "Minion" in c["apiName"]: + continue # PvE monsters, summons (z.B. TFT17_IvernMinion) units[c["apiName"]] = { "api_name": c["apiName"], "name": c["name"], diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1e4031e..bb02d6a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,7 +15,7 @@ const standings = computed(() => { const rows = [ { name: 'Du', is_player: true, hp: Math.max(p.hp, 0), alive: p.hp > 0, - score: p.score, level: p.level, archetype: '', + score: p.score, level: p.level, gold: p.gold, archetype: '', placement: s.value.placement, board: [], }, ...s.value.opponents, diff --git a/frontend/src/components/OpponentList.vue b/frontend/src/components/OpponentList.vue index 5c6d27e..494b36a 100644 --- a/frontend/src/components/OpponentList.vue +++ b/frontend/src/components/OpponentList.vue @@ -17,6 +17,8 @@ const expanded = ref(null)
{{ p.name }} {{ p.archetype }} + L{{ p.level }} + 🪙{{ p.gold }} ⚡ {{ p.score }} {{ p.hp }} #{{ p.placement }} @@ -48,6 +50,8 @@ h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: .oname { flex: 1; font-weight: 600; } .me .oname { color: #7de3d4; } .arch { color: #7d86a0; font-size: 11px; } +.lvl { color: #8a92a8; font-size: 11px; font-weight: 700; } +.goldval { color: #ffd75e; font-size: 11px; } .strength { color: #e5a531; font-size: 12px; font-weight: 600; } .hp { font-weight: 700; color: #7de37d; } .placement { color: #8a92a8; }