Files
tft/backend/tft/sim/game.py

253 lines
9.6 KiB
Python

"""Game orchestration: one human player + 7 policy bots under identical rules."""
import random
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.
if self.round["kind"] == "carousel":
for p in self.players:
self._carousel(p)
self.idx += 1
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 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 _maybe_offer_augment(self) -> None:
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))
# ---- 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)
def toggle_lock(self) -> None:
self.player.shop_locked = not self.player.shop_locked
def pick_augment(self, choice: int) -> None:
player.pick_augment(self.player, choice)
# ---- 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
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
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 für alle ab."""
while True:
if self.idx + 1 >= len(self.rounds):
self._finish_by_hp()
return
self.idx += 1
if self.round["kind"] != "carousel":
break
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 _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))