M7+M8: sim engine, combat, archetype bots, autoplay policies
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
81
backend/tests/test_game.py
Normal file
81
backend/tests/test_game.py
Normal 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
96
backend/tests/test_sim.py
Normal 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"]
|
||||
@@ -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()
|
||||
|
||||
0
backend/tft/sim/__init__.py
Normal file
0
backend/tft/sim/__init__.py
Normal file
119
backend/tft/sim/autoplay.py
Normal file
119
backend/tft/sim/autoplay.py
Normal 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
73
backend/tft/sim/bots.py
Normal 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
27
backend/tft/sim/combat.py
Normal 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
|
||||
40
backend/tft/sim/economy.py
Normal file
40
backend/tft/sim/economy.py
Normal 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
278
backend/tft/sim/game.py
Normal 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
25
backend/tft/sim/pool.py
Normal 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
16
backend/tft/sim/rounds.py
Normal 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
19
backend/tft/sim/shop.py
Normal 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
|
||||
Reference in New Issue
Block a user