Files
tft/backend/tft/sim/game.py
2026-07-24 11:24:24 +02:00

337 lines
14 KiB
Python

"""Game orchestration: one human player + 7 policy bots under identical rules."""
import random
from tft.model.score import board_profiles
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
BOT_NAMES = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"]
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:
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.log: list[str] = []
self.player = player.new_player("Du", "human", cfg)
archetypes = list(policy.ARCHETYPES)
self.bots = [
player.new_player(BOT_NAMES[i], archetypes[i % len(archetypes)], cfg)
for i in range(7)
]
# 1-1-Carousel läuft automatisch für alle ab, dann startet 1-2.
# Carousels geben keine XP.
if self.round["kind"] == "carousel":
for p in self.players:
self._carousel(p)
self.idx += 1
# Bei 1-2 gibt es noch keinen Shop — niemand hat Gold.
for p in self.players:
p.shop = [None] * cfg["shop"]["slots"]
self._maybe_offer_augment()
self._bots_act()
# ---- helpers ----
@property
def players(self) -> list[PlayerState]:
return [self.player] + self.bots
@property
def round(self) -> dict:
return self.rounds[self.idx]
@property
def over(self) -> bool:
return self.player.placement is not None
def player_score(self) -> float:
return player.score(self.player, self.artifact)
def _alive(self) -> list[PlayerState]:
return [p for p in self.players if p.alive]
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 _bots_act(self) -> None:
"""Bots ziehen beim Rundenstart — ihre Boards und Scores stehen damit
für die ganze Planungsphase fest und kämpfen unverändert."""
for b in self.bots:
if b.alive:
policy.act(b, self.pool, self.artifact, self.cfg, self.rng,
self.round, policy.ARCHETYPES[b.archetype])
player.fill_board(b, self.artifact)
def _maybe_offer_augment(self) -> None:
if not self.round.get("augment"):
return
augments = self.artifact["static"]["augments"]
specs = self.artifact.get("augment_specs", {})
pool = [a for a in (self.artifact.get("augment_pool") or list(augments))
if a in augments and specs.get(a, {}).get("offerable", True)]
# Pro Runde eine Stufe rollen; alle Angebote (Spieler + Bots) kommen daraus.
weights = self.cfg.get("augments", {}).get("tier_weights", {}).get(
self.round["label"], [35, 50, 15]
)
tier = self.rng.choices((1, 2, 3), weights=weights)[0]
tier_pool = [a for a in pool if (augments[a].get("tier") or 2) == tier] or pool
available = [a for a in tier_pool 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 tier_pool if a not in b.augments]
if choices:
chosen = self.rng.choice(choices)
b.augments.append(chosen)
self._apply_augment(b, chosen)
# ---- human actions (delegation) ----
def buy(self, slot: int) -> None:
player.buy(self.player, slot, self.pool, self.artifact)
def sell(self, where: str, idx: int) -> None:
player.sell(self.player, where, idx, self.pool, self.artifact)
def reroll(self) -> None:
player.reroll(self.player, self.pool, self.cfg, self.rng)
def buy_xp(self) -> None:
player.buy_xp(self.player, self.cfg)
def move(self, where: str, idx: int) -> None:
player.move(self.player, where, idx)
def equip(self, item_idx: int, board_idx: int) -> None:
player.equip(self.player, item_idx, board_idx, self.artifact)
def toggle_lock(self) -> None:
self.player.shop_locked = not self.player.shop_locked
def pick_augment(self, choice: int) -> None:
api = self.player.augment_offer[choice] \
if 0 <= choice < len(self.player.augment_offer) else None
player.pick_augment(self.player, choice)
if api:
self._apply_augment(self.player, api)
def _apply_augment(self, p: PlayerState, api: str) -> None:
"""Deterministische Augment-Atome sofort bzw. als Dauereffekt anwenden."""
spec = self.artifact.get("augment_specs", {}).get(api, {})
for atom in spec.get("atoms", []):
kind = atom["kind"]
if kind == "gold_now":
p.gold += atom["amount"]
elif kind == "gold_per_stage":
p.gold_per_stage += atom["amount"]
elif kind == "xp_now":
p.level, p.xp = economy.apply_xp(p.level, p.xp, atom["amount"], self.cfg)
elif kind == "xp_per_round":
p.xp_per_round += atom["amount"]
elif kind == "player_hp":
p.hp += atom["amount"]
elif kind == "components":
player.grant_loot(p, atom["count"], 0, self.artifact, self.rng)
elif kind == "completed_items":
pool = self.artifact.get("item_pool") or []
for _ in range(atom["count"]):
if pool:
p.items.append(self.rng.choice(pool))
elif kind == "unit_grant":
self._grant_units(p, atom)
# team_buff wirkt im Score, nicht hier.
def _grant_units(self, p: PlayerState, atom: dict) -> None:
apis = list(atom.get("units", []))
if "cost" in atom:
for _ in range(atom.get("count", 1)):
candidates = self.pool.units_of_cost(atom["cost"])
if candidates:
apis.append(self.rng.choice(candidates))
for api in apis:
if len(p.bench) >= player.BENCH_SIZE or self.pool.available(api) < 1:
continue
self.pool.take(api)
p.bench.append({"api_name": api, "stars": 1, "items": []})
player.merge(p, api, 1)
# ---- 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.player.augment_offer:
self.pick_augment(self.rng.randrange(len(self.player.augment_offer)))
rnd = self.round
# Bots haben schon beim Rundenstart gezogen (_bots_act).
# swap=False: die manuelle Aufstellung des Spielers bleibt unangetastet.
if self.player.alive:
player.fill_board(self.player, self.artifact, swap=False)
results = self._resolve(rnd)
self._check_eliminations()
if self.over:
return
is_pvp = rnd["kind"] == "pvp"
for p in self._alive():
won = results.get(p.name, False)
# Nur PvP zählt für Streak, Streak-Gold und Siegbonus.
if is_pvp:
p.streak = max(p.streak, 0) + 1 if won else min(p.streak, 0) - 1
p.gold += economy.round_income(
p.gold, p.streak if is_pvp else 0, is_pvp and won, rnd["label"], self.cfg
)
p.level, p.xp = economy.apply_xp(
p.level, p.xp,
self.cfg["xp"]["passive_per_round"] + p.xp_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)
# Unentschieden (None) zählt für beide als nicht gewonnen.
results[a.name] = a_wins is True
results[b.name] = a_wins is False
if odd is not None:
# Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler.
res = combat.fight(board_profiles(odd.board, self.artifact),
board_profiles(ghost_src.board, self.artifact),
self.rng, self.cfg)
won = res.winner == "a"
results[odd.name] = won
if not won:
survivors = res.survivors_b if res.winner == "b" else []
dmg = combat.player_damage(rnd["stage"], survivors, 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 | None:
res = combat.fight(board_profiles(a.board, self.artifact),
board_profiles(b.board, self.artifact),
self.rng, self.cfg)
if res.winner is None:
# Unentschieden: beide nehmen den Basis-Schaden der Stage.
dmg = combat.player_damage(rnd["stage"], [], self.cfg)
a.hp -= dmg
b.hp -= dmg
if a is self.player or b is self.player:
other = b if a is self.player else a
self.log.append(f"{rnd['label']}: Unentschieden vs {other.name} (-{dmg} HP)")
return None
a_wins = res.winner == "a"
winner, loser = (a, b) if a_wins else (b, a)
survivors = res.survivors_a if a_wins else res.survivors_b
dmg = combat.player_damage(rnd["stage"], survivors, 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 für alle ab."""
prev_stage = self.round["stage"]
while True:
if self.idx + 1 >= len(self.rounds):
self._finish_by_hp()
return
self.idx += 1
if self.round["kind"] != "carousel":
break
# Carousel gibt weder Einkommen noch XP — nur Loot und Unit.
for p in self._alive():
self._carousel(p)
if self.round["stage"] != prev_stage:
for p in self._alive():
p.gold += p.gold_per_stage # Augment: Gold zum Stage-Start
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()
self._bots_act()
def _finish_by_hp(self) -> None:
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))