Bots spielen echt: PlayerState + geteilte Policy, Pairing mit Ghost, Pool-Erhaltung, Streak-Fix, Meepsie-Filter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 06:47:05 +02:00
parent c0cd303eb0
commit ca471d2b32
10 changed files with 547 additions and 399 deletions

View File

@@ -103,3 +103,84 @@ def test_carousel_grants_unit_and_loot(art, cfg):
game = Game(art, cfg, seed=5) game = Game(art, cfg, seed=5)
assert len(game.bench) == 1 # Unit vom 1-1-Carousel assert len(game.bench) == 1 # Unit vom 1-1-Carousel
assert len(game.items) == 1 # Item-Komponente vom 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

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel
from tft import paths from tft import paths
from tft.model.score import active_trait_tiers from tft.model.score import active_trait_tiers
from tft.sim import economy from tft.sim import economy
from tft.sim import player as sim_player
from tft.sim.game import Game, InvalidAction from tft.sim.game import Game, InvalidAction
app = FastAPI(title="tft-sim") 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), "name": b.name, "archetype": b.archetype, "hp": max(b.hp, 0),
"alive": b.alive, "placement": b.placement, "alive": b.alive, "placement": b.placement,
"level": b.level(stage) if b.alive else None, "level": b.level if b.alive else None,
"score": round(b.score(stage, game.artifact), 1) 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], "board": [unit_view(game, u) for u in b.board],
} }
for b in game.bots for b in game.bots

View File

@@ -1,11 +1,7 @@
"""Scripted policies playing full games headless — sanity check for the sim.""" """Scripted policies playing full games headless — sanity check for the sim."""
from tft.sim.game import Game, InvalidAction from tft.sim import policy
from tft.sim.game import Game
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: def play_afk(game: Game) -> int:
@@ -15,103 +11,22 @@ def play_afk(game: Game) -> int:
def play_econ(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: while not game.over:
if game.augment_offer: policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
game.pick_augment(0) game.round, params)
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() game.step()
return game.placement 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} 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 = [] placements = []
for i in range(n): for i in range(n):
game = Game(artifact, cfg, seed=seed + i) game = Game(artifact, cfg, seed=seed + i)
placements.append(POLICIES[policy](game)) placements.append(POLICIES[policy_name](game))
return { return {
"games": n, "games": n,
"avg_placement": sum(placements) / n, "avg_placement": sum(placements) / n,

View File

@@ -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)

View File

@@ -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 import random
from tft.model.score import score_board from tft.sim import combat, economy, player, policy
from tft.sim import combat, economy from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401
from tft.sim.bots import ARCHETYPES, Bot
from tft.sim.pool import Pool from tft.sim.pool import Pool
from tft.sim.rounds import schedule from tft.sim.rounds import schedule
from tft.sim.shop import roll
BENCH_SIZE = 9 BOT_NAMES = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"]
MAX_ITEMS_PER_UNIT = 3
class InvalidAction(Exception): def pair_players(alive: list[PlayerState], rng: random.Random):
pass """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: class Game:
@@ -25,167 +34,90 @@ class Game:
self.pool = Pool(artifact["static"]["units"], cfg["pool"]["sizes"]) self.pool = Pool(artifact["static"]["units"], cfg["pool"]["sizes"])
self.rounds = schedule(cfg) self.rounds = schedule(cfg)
self.idx = 0 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] = [] self.log: list[str] = []
names = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"] self.player = player.new_player("Du", "human", cfg)
archetypes = list(ARCHETYPES) archetypes = list(policy.ARCHETYPES)
self.bots = [ 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": if self.round["kind"] == "carousel":
self._resolve(self.round) for p in self.players:
self._carousel(p)
self.idx += 1 self.idx += 1
self._refresh_bots() for p in self.players:
self._reroll_shop() player.refresh_shop(p, self.pool, cfg, self.rng)
self._maybe_offer_augment() self._maybe_offer_augment()
# ---- helpers ---- # ---- helpers ----
@property
def players(self) -> list[PlayerState]:
return [self.player] + self.bots
@property @property
def round(self) -> dict: def round(self) -> dict:
return self.rounds[self.idx] return self.rounds[self.idx]
@property
def alive(self) -> bool:
return self.hp > 0 and self.placement != 1
@property @property
def over(self) -> bool: def over(self) -> bool:
return self.placement is not None return self.player.placement is not None
def player_score(self) -> float: 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: def _alive(self) -> list[PlayerState]:
self.shop = roll(self.pool, self.level, self.cfg, self.rng) return [p for p in self.players if p.alive]
def _refresh_bots(self) -> None: def _carousel(self, p: PlayerState) -> None:
for bot in self.bots: player.grant_loot(p, *self.cfg["loot"]["carousel"], self.artifact, self.rng)
if bot.alive: player.grab_carousel_unit(p, self.pool, self.rng)
bot.refresh_board(self.round["stage"], self.pool, self.artifact, if p is self.player:
self.cfg, self.rng) self.log.append(f"{self.round['label']}: Carousel")
def _maybe_offer_augment(self) -> None: def _maybe_offer_augment(self) -> None:
if self.round.get("augment") and not self.augment_offer: if not self.round.get("augment"):
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 = 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 return
keep, *rest = copies available = [a for a in self.artifact["static"]["augments"]
keep["stars"] += 1 if a not in self.player.augments]
for u in rest: if available and not self.player.augment_offer:
keep["items"].extend(u["items"]) self.player.augment_offer = self.rng.sample(available, min(3, len(available)))
(self.board if u in self.board else self.bench).remove(u) for b in self.bots:
keep["items"] = keep["items"][:MAX_ITEMS_PER_UNIT] if not b.alive:
stars += 1 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))
# ---- player actions ---- # ---- human actions (delegation) ----
def buy(self, slot: int) -> None: def buy(self, slot: int) -> None:
if not (0 <= slot < len(self.shop)) or self.shop[slot] is None: player.buy(self.player, slot, self.pool, self.artifact)
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: def sell(self, where: str, idx: int) -> None:
units = self.board if where == "board" else self.bench player.sell(self.player, where, idx, self.pool, self.artifact)
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"])
def reroll(self) -> None: def reroll(self) -> None:
cost = self.cfg["shop"]["reroll_cost"] player.reroll(self.player, self.pool, self.cfg, self.rng)
if self.gold < cost:
raise InvalidAction("not enough gold")
self.gold -= cost
self._reroll_shop()
def buy_xp(self) -> None: def buy_xp(self) -> None:
xp = self.cfg["xp"] player.buy_xp(self.player, self.cfg)
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: def move(self, where: str, idx: int) -> None:
src, dst = (self.bench, self.board) if where == "bench" else (self.board, self.bench) player.move(self.player, where, idx)
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: def equip(self, item_idx: int, board_idx: int) -> None:
if not (0 <= item_idx < len(self.items)): player.equip(self.player, item_idx, board_idx)
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 toggle_lock(self) -> None: 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: def pick_augment(self, choice: int) -> None:
if not self.augment_offer: player.pick_augment(self.player, choice)
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 ---- # ---- round resolution ----
@@ -193,121 +125,128 @@ class Game:
"""Resolve the current round and advance to the next planning phase.""" """Resolve the current round and advance to the next planning phase."""
if self.over: if self.over:
raise InvalidAction("game over") raise InvalidAction("game over")
if self.augment_offer: if self.player.augment_offer:
self.pick_augment(self.rng.randrange(len(self.augment_offer))) self.pick_augment(self.rng.randrange(len(self.player.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))
rnd = self.round rnd = self.round
won = self._resolve(rnd) for b in self.bots:
self._bot_fights(rnd) 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() self._check_eliminations()
if self.over: if self.over:
return return
self.streak = self.streak + 1 if won else min(self.streak, 0) - 1 for p in self._alive():
self.gold += economy.round_income(self.gold, self.streak, won, rnd["label"], self.cfg) won = results.get(p.name, False)
self.level, self.xp = economy.apply_xp( p.streak = max(p.streak, 0) + 1 if won else min(p.streak, 0) - 1
self.level, self.xp, self.cfg["xp"]["passive_per_round"], self.cfg 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() 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: 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: while True:
if self.idx + 1 >= len(self.rounds): if self.idx + 1 >= len(self.rounds):
self._finish_by_hp() self._finish_by_hp()
return return
prev_stage = self.round["stage"]
self.idx += 1 self.idx += 1
if self.round["stage"] != prev_stage:
self._refresh_bots()
if self.round["kind"] != "carousel": if self.round["kind"] != "carousel":
break break
self._resolve(self.round) for p in self._alive():
self.gold += economy.round_income( self._carousel(p)
self.gold, self.streak, False, self.round["label"], self.cfg p.gold += economy.round_income(
p.gold, p.streak, False, self.round["label"], self.cfg
) )
self.level, self.xp = economy.apply_xp( p.level, p.xp = economy.apply_xp(
self.level, self.xp, self.cfg["xp"]["passive_per_round"], self.cfg p.level, p.xp, self.cfg["xp"]["passive_per_round"], self.cfg
) )
if not self.shop_locked: for p in self._alive():
self._reroll_shop() 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._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: def _finish_by_hp(self) -> None:
standings = sorted( standings = sorted(self._alive(), key=lambda p: -p.hp)
[("player", self.hp)] + [(b.name, b.hp) for b in self.bots if b.alive], for rank, p in enumerate(standings, start=1):
key=lambda x: -x[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 rank, (name, _) in enumerate(standings, start=1):
if name == "player":
self.placement = rank for _attr in ("hp", "gold", "level", "xp", "board", "bench", "items", "augments",
else: "streak", "shop", "shop_locked", "augment_offer", "placement"):
next(b for b in self.bots if b.name == name).placement = rank setattr(Game, _attr, _delegate(_attr))

183
backend/tft/sim/player.py Normal file
View File

@@ -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))

96
backend/tft/sim/policy.py Normal file
View File

@@ -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

View File

@@ -29,8 +29,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
units = {} units = {}
for c in set_data["champions"]: for c in set_data["champions"]:
if not c["traits"]: if not c["traits"] or "Minion" in c["apiName"]:
continue # PvE monsters, summons continue # PvE monsters, summons (z.B. TFT17_IvernMinion)
units[c["apiName"]] = { units[c["apiName"]] = {
"api_name": c["apiName"], "api_name": c["apiName"],
"name": c["name"], "name": c["name"],

View File

@@ -15,7 +15,7 @@ const standings = computed(() => {
const rows = [ const rows = [
{ {
name: 'Du', is_player: true, hp: Math.max(p.hp, 0), alive: p.hp > 0, 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: [], placement: s.value.placement, board: [],
}, },
...s.value.opponents, ...s.value.opponents,

View File

@@ -17,6 +17,8 @@ const expanded = ref(null)
<div class="row"> <div class="row">
<span class="oname">{{ p.name }}</span> <span class="oname">{{ p.name }}</span>
<span v-if="p.archetype" class="arch">{{ p.archetype }}</span> <span v-if="p.archetype" class="arch">{{ p.archetype }}</span>
<span v-if="p.alive && p.level" class="lvl">L{{ p.level }}</span>
<span v-if="p.alive && p.gold !== undefined" class="goldval">🪙{{ p.gold }}</span>
<span v-if="p.alive" class="strength" title="Boardstärke"> {{ p.score }}</span> <span v-if="p.alive" class="strength" title="Boardstärke"> {{ p.score }}</span>
<span v-if="p.alive" class="hp">{{ p.hp }}</span> <span v-if="p.alive" class="hp">{{ p.hp }}</span>
<span v-else class="placement">#{{ p.placement }}</span> <span v-else class="placement">#{{ p.placement }}</span>
@@ -48,6 +50,8 @@ h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom:
.oname { flex: 1; font-weight: 600; } .oname { flex: 1; font-weight: 600; }
.me .oname { color: #7de3d4; } .me .oname { color: #7de3d4; }
.arch { color: #7d86a0; font-size: 11px; } .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; } .strength { color: #e5a531; font-size: 12px; font-weight: 600; }
.hp { font-weight: 700; color: #7de37d; } .hp { font-weight: 700; color: #7de37d; }
.placement { color: #8a92a8; } .placement { color: #8a92a8; }