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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
183
backend/tft/sim/player.py
Normal file
183
backend/tft/sim/player.py
Normal 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
96
backend/tft/sim/policy.py
Normal 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
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user