263 lines
10 KiB
Python
263 lines
10 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.
|
|
# 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()
|
|
|
|
# ---- 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
|
|
augments = self.artifact["static"]["augments"]
|
|
pool = [a for a in (self.artifact.get("augment_pool") or list(augments))
|
|
if a in augments]
|
|
# 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:
|
|
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, self.artifact)
|
|
|
|
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
|
|
|
|
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"], 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
|
|
# Carousel gibt weder Einkommen noch XP — nur Loot und Unit.
|
|
for p in self._alive():
|
|
self._carousel(p)
|
|
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))
|