M7+M8: sim engine, combat, archetype bots, autoplay policies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 00:35:30 +02:00
parent a5b9f024e9
commit 8a474e37a4
12 changed files with 792 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import pytest
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
from tft.sim.autoplay import play_afk, play_econ
from tft.sim.game import Game, InvalidAction
from tests.test_sim import make_static
@pytest.fixture(scope="module")
def art():
static = make_static()
static["meta"]["patch"] = "test"
return artifact_mod.build(static)
@pytest.fixture(scope="module")
def cfg():
return load_constants(17)
def test_afk_game_terminates_last(art, cfg):
placements = [play_afk(Game(art, cfg, seed=s)) for s in range(20)]
assert all(1 <= p <= 8 for p in placements)
assert sum(placements) / len(placements) > 6.5
def test_econ_beats_afk(art, cfg):
afk = [play_afk(Game(art, cfg, seed=s)) for s in range(30)]
econ = [play_econ(Game(art, cfg, seed=1000 + s)) for s in range(30)]
assert sum(econ) / len(econ) < sum(afk) / len(afk)
def test_same_seed_same_outcome(art, cfg):
assert play_econ(Game(art, cfg, seed=7)) == play_econ(Game(art, cfg, seed=7))
def test_buy_and_merge(art, cfg):
game = Game(art, cfg, seed=1)
game.gold = 100
api = next(a for a in game.shop if a)
unit = {"api_name": api, "stars": 1, "items": []}
game.bench = [dict(unit), dict(unit)]
game.pool.take(api, 2)
game.shop = [api] * 5
game.buy(0)
assert len(game.bench) == 1
assert game.bench[0]["stars"] == 2
def test_sell_refunds_pool_and_gold(art, cfg):
game = Game(art, cfg, seed=1)
game.gold = 100
game.shop[0] = "U1_0"
before_pool = game.pool.available("U1_0")
game.buy(0)
gold_after_buy = game.gold
game.sell("bench", len(game.bench) - 1)
assert game.gold == gold_after_buy + 1
assert game.pool.available("U1_0") == before_pool
def test_board_cap_is_level(art, cfg):
game = Game(art, cfg, seed=1)
game.bench = [{"api_name": "U1_0", "stars": 1, "items": []} for _ in range(3)]
game.level = 2
game.move("bench", 0)
game.move("bench", 0)
with pytest.raises(InvalidAction, match="board full"):
game.move("bench", 0)
def test_invalid_actions_raise(art, cfg):
game = Game(art, cfg, seed=1)
game.gold = 0
with pytest.raises(InvalidAction):
game.reroll()
with pytest.raises(InvalidAction):
game.buy_xp()
with pytest.raises(InvalidAction):
game.sell("bench", 0)

96
backend/tests/test_sim.py Normal file
View File

@@ -0,0 +1,96 @@
import random
import pytest
from tft.constants.loader import load_constants
from tft.sim import economy
from tft.sim.pool import Pool
from tft.sim.rounds import schedule
from tft.sim.shop import roll
def make_static(per_tier: int = 13) -> dict:
units = {}
for cost in range(1, 6):
for i in range(per_tier):
units[f"U{cost}_{i}"] = {
"cost": cost,
"traits": ["T1"],
"role": "Tank",
"stats": {"hp": 600 + 100 * cost, "armor": 40, "magicResist": 40,
"damage": 55, "attackSpeed": 0.7, "mana": 60,
"initialMana": 0, "range": 1},
}
return {
"meta": {"set": 17},
"units": units,
"traits": {"T1": {"breakpoints": [{"min_units": 2, "style": 1}]}},
"items": {f"TFT_Item_{i}": {} for i in range(10)},
"augments": {f"TFT17_Augment_{i}": {} for i in range(8)},
}
@pytest.fixture(scope="module")
def cfg():
return load_constants(17)
@pytest.fixture
def static():
return make_static()
def test_pool_take_put_back(static, cfg):
pool = Pool(static["units"], cfg["pool"]["sizes"])
assert pool.available("U1_0") == 30
pool.take("U1_0", 3)
assert pool.available("U1_0") == 27
pool.put_back("U1_0", 3)
assert pool.available("U1_0") == 30
with pytest.raises(ValueError):
pool.take("U5_0", 10)
def test_shop_odds_empirical(static, cfg):
pool = Pool(static["units"], cfg["pool"]["sizes"])
rng = random.Random(42)
counts = [0] * 5
n = 10_000
for _ in range(n // cfg["shop"]["slots"]):
for api in roll(pool, 8, cfg, rng):
counts[static["units"][api]["cost"] - 1] += 1
expected = cfg["shop"]["odds"][7]
for tier in range(5):
assert counts[tier] / n == pytest.approx(expected[tier] / 100, abs=0.02)
def test_interest_cap(cfg):
assert economy.interest(0, cfg) == 0
assert economy.interest(39, cfg) == 3
assert economy.interest(80, cfg) == cfg["gold"]["interest_cap"]
def test_streak_gold(cfg):
assert economy.streak_gold(1, cfg) == 0
assert economy.streak_gold(3, cfg) == 1
assert economy.streak_gold(-5, cfg) == 2
assert economy.streak_gold(9, cfg) == 3
def test_apply_xp_levels_up(cfg):
level, xp = economy.apply_xp(1, 0, 4, cfg)
assert level == 3 # 1->2 costs 2, 2->3 costs 2
assert xp == 0
level, _ = economy.apply_xp(9, 0, 1000, cfg)
assert level == cfg["xp"]["max_level"]
def test_schedule(cfg):
rounds = schedule(cfg)
labels = [r["label"] for r in rounds]
assert labels[:4] == ["1-1", "1-2", "1-3", "1-4"]
assert rounds[0]["kind"] == "carousel"
aug = [r["label"] for r in rounds if r["augment"]]
assert aug == cfg["rounds"]["augment_rounds"]
stage2 = [r for r in rounds if r["stage"] == 2]
assert [r["kind"] for r in stage2] == cfg["rounds"]["stage_n"]

View File

@@ -25,6 +25,10 @@ def main() -> None:
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
p_auto = sub.add_parser("autoplay", help="run scripted games headless")
p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ")
p_auto.add_argument("--games", type=int, default=200)
args = parser.parse_args()
if args.command == "fetch-static":
@@ -80,6 +84,20 @@ def main() -> None:
print(f"holdout matches: {result['matches']}")
print(f"mean spearman (score vs placement): {result['mean_spearman']:.3f}")
elif args.command == "autoplay":
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
from tft.sim.autoplay import run
set_number = current_set()
result = run(
artifact_mod.load(set_number), load_constants(set_number),
args.policy, args.games,
)
print(f"{args.policy}: {result['games']} games, "
f"avg placement {result['avg_placement']:.2f}, "
f"top4 {result['top4_rate']:.0%}")
if __name__ == "__main__":
main()

View File

119
backend/tft/sim/autoplay.py Normal file
View File

@@ -0,0 +1,119 @@
"""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)
def play_afk(game: Game) -> int:
while not game.over:
game.step()
return game.placement
def play_econ(game: Game) -> int:
"""Buy merges + strong units, keep board full, level with surplus gold."""
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)
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:
placements = []
for i in range(n):
game = Game(artifact, cfg, seed=seed + i)
placements.append(POLICIES[policy](game))
return {
"games": n,
"avg_placement": sum(placements) / n,
"top4_rate": sum(1 for p in placements if p <= 4) / n,
}

73
backend/tft/sim/bots.py Normal file
View File

@@ -0,0 +1,73 @@
"""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 = 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)

27
backend/tft/sim/combat.py Normal file
View File

@@ -0,0 +1,27 @@
"""Combat = score comparison. Relative score difference -> win probability -> damage."""
import math
import random
def win_probability(score_a: float, score_b: float, cfg: dict) -> float:
mean = (score_a + score_b) / 2
if mean <= 0:
return 0.5
d = (score_a - score_b) / mean
return 1 / (1 + math.exp(-d / cfg["combat"]["tau"]))
def resolve(score_a: float, score_b: float, cfg: dict, rng: random.Random) -> bool:
"""True if A wins. Adds noise on top of the probability."""
noise = rng.gauss(0, cfg["combat"]["variance"])
return rng.random() < min(max(win_probability(score_a, score_b, cfg) + noise, 0.02), 0.98)
def damage(stage: int, winner_score: float, loser_score: float, winner_units: int, cfg: dict) -> int:
d = cfg["damage"]
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
mean = (winner_score + loser_score) / 2 or 1
margin = abs(winner_score - loser_score) / mean
surviving = max(1, round(winner_units * min(margin, 1.0)))
return base + d["per_surviving_unit"] * surviving

View File

@@ -0,0 +1,40 @@
"""Pure gold/XP math. State lives in game.py."""
def interest(gold: int, cfg: dict) -> int:
g = cfg["gold"]
return min(gold // 10 * g["interest_per_10"], g["interest_cap"])
def streak_gold(streak: int, cfg: dict) -> int:
bonus = 0
for min_streak, gold in cfg["gold"]["streak_gold"]:
if abs(streak) >= min_streak:
bonus = gold
return bonus
def round_income(gold: int, streak: int, won: bool, round_label: str, cfg: dict) -> int:
g = cfg["gold"]
base = g["early_income"].get(round_label, g["base_income"])
income = base + interest(gold, cfg) + streak_gold(streak, cfg)
if won:
income += g["win_bonus"]
return income
def xp_to_next(level: int, cfg: dict) -> int | None:
to_next = cfg["xp"]["to_next"]
if level >= cfg["xp"]["max_level"]:
return None
return to_next[level - 1]
def apply_xp(level: int, xp: int, gained: int, cfg: dict) -> tuple[int, int]:
xp += gained
while (need := xp_to_next(level, cfg)) is not None and xp >= need:
xp -= need
level += 1
if level >= cfg["xp"]["max_level"]:
xp = 0
return level, xp

278
backend/tft/sim/game.py Normal file
View File

@@ -0,0 +1,278 @@
"""Game state and action dispatch: one human player vs 7 archetype bots."""
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.pool import Pool
from tft.sim.rounds import schedule
from tft.sim.shop import roll
BENCH_SIZE = 9
MAX_ITEMS_PER_UNIT = 3
class InvalidAction(Exception):
pass
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.hp = cfg["damage"]["player_hp"]
self.gold = cfg["gold"]["starting_gold"]
self.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.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.bots = [
Bot(names[i], archetypes[i % len(archetypes)], self.hp) for i in range(7)
]
self._refresh_bots()
self._reroll_shop()
self._maybe_offer_augment()
# ---- helpers ----
@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
def player_score(self) -> float:
return score_board(self.board, self.augments, self.artifact)
def _reroll_shop(self) -> None:
self.shop = roll(self.pool, self.level, self.cfg, self.rng)
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 _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)))
def _grant_loot(self, components: int, gold: int) -> None:
items = 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 ----
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)
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)
self.gold += self.artifact["static"]["units"][u["api_name"]]["cost"] * copies
self.items.extend(u["items"])
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()
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)
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))
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))
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 = []
# ---- 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.augment_offer:
self.pick_augment(self.rng.randrange(len(self.augment_offer)))
rnd = self.round
won = self._resolve(rnd)
self._bot_fights(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
)
if self.idx + 1 >= len(self.rounds):
self._finish_by_hp()
return
prev_stage = rnd["stage"]
self.idx += 1
if self.round["stage"] != prev_stage:
self._refresh_bots()
self._reroll_shop()
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

25
backend/tft/sim/pool.py Normal file
View File

@@ -0,0 +1,25 @@
"""Shared unit pool: finite copies per unit, drawn by everyone."""
class Pool:
def __init__(self, static_units: dict, sizes: list[int]):
self.copies = {
api: sizes[u["cost"] - 1]
for api, u in static_units.items()
if 1 <= u["cost"] <= len(sizes)
}
self.cost_of = {api: u["cost"] for api, u in static_units.items()}
def available(self, api_name: str) -> int:
return self.copies.get(api_name, 0)
def units_of_cost(self, cost: int) -> list[str]:
return [a for a, c in self.cost_of.items() if c == cost and self.copies[a] > 0]
def take(self, api_name: str, n: int = 1) -> None:
if self.copies[api_name] < n:
raise ValueError(f"pool exhausted for {api_name}")
self.copies[api_name] -= n
def put_back(self, api_name: str, n: int = 1) -> None:
self.copies[api_name] += n

16
backend/tft/sim/rounds.py Normal file
View File

@@ -0,0 +1,16 @@
"""Stage/round schedule: labels, types, augment rounds."""
MAX_STAGE = 9
def schedule(cfg: dict) -> list[dict]:
r = cfg["rounds"]
rounds = []
for i, kind in enumerate(r["stage1"], start=1):
rounds.append({"label": f"1-{i}", "stage": 1, "kind": kind})
for stage in range(2, MAX_STAGE + 1):
for i, kind in enumerate(r["stage_n"], start=1):
rounds.append({"label": f"{stage}-{i}", "stage": stage, "kind": kind})
for rnd in rounds:
rnd["augment"] = rnd["label"] in r["augment_rounds"]
return rounds

19
backend/tft/sim/shop.py Normal file
View File

@@ -0,0 +1,19 @@
"""Shop rolls: tier by level odds, unit weighted by remaining pool copies."""
import random
from tft.sim.pool import Pool
def roll(pool: Pool, level: int, cfg: dict, rng: random.Random) -> list[str | None]:
odds = cfg["shop"]["odds"][level - 1]
slots = []
for _ in range(cfg["shop"]["slots"]):
tier = rng.choices(range(1, 6), weights=odds)[0]
candidates = pool.units_of_cost(tier)
if not candidates:
slots.append(None)
continue
weights = [pool.available(a) for a in candidates]
slots.append(rng.choices(candidates, weights=weights)[0])
return slots