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("build-artifact", help="build analysis.json from static data + endboards")
|
||||||
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "fetch-static":
|
if args.command == "fetch-static":
|
||||||
@@ -80,6 +84,20 @@ def main() -> None:
|
|||||||
print(f"holdout matches: {result['matches']}")
|
print(f"holdout matches: {result['matches']}")
|
||||||
print(f"mean spearman (score vs placement): {result['mean_spearman']:.3f}")
|
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__":
|
if __name__ == "__main__":
|
||||||
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