This commit is contained in:
team3
2026-07-24 11:24:24 +02:00
parent f41ac76b57
commit 848c8ea1aa
41 changed files with 1776 additions and 15 deletions

View File

@@ -114,6 +114,18 @@ def _pool_total(game, api):
return n
def test_step_keeps_player_board_setup(art, cfg):
"""Die manuelle Aufstellung wird nie durch stärkere Bank-Units ersetzt."""
game = Game(art, cfg, seed=8)
game.player.level = 2
game.player.board = [{"api_name": "U1_0", "stars": 1, "items": []},
{"api_name": "U1_1", "stars": 1, "items": []}]
game.player.bench = [{"api_name": "U5_0", "stars": 2, "items": []}]
before = [u["api_name"] for u in game.player.board]
game.step()
assert [u["api_name"] for u in game.player.board] == before
def test_pool_conservation(art, cfg):
for seed in (1, 2, 3):
game = Game(art, cfg, seed=seed)

View File

@@ -60,6 +60,21 @@ def test_parse_resolves_spell_damage():
assert unit["spell_scaling"] == "ap"
def test_resolve_spell_on_attack_passive():
diana_like = {
"desc": "<spellPassive>Passive:</spellPassive> Attacks deal "
"<magicDamage>@ModifiedBonusDamageToAttacks@ (%i:scaleAP%)</magicDamage> "
"bonus magic damage.",
"variables": [{"name": "BonusDamageToAttacks", "value": [0, 52, 78, 135, 230]}],
}
r = resolve_spell(diana_like)
assert r["spell_on_attack"] is True
assert r["spell_damage"] == [0, 52, 78, 135, 230]
cast = parse(RAW, set_override=17)["units"]["TFT17_Mage"]
assert cast["spell_on_attack"] is False
def test_resolve_spell_adaptive_and_fallback():
adaptive = {
"desc": "Deal <magicDamage>@TotalDamage@</magicDamage> damage. %i:scaleAP% %i:scaleAD%",

View File

@@ -45,6 +45,17 @@ def test_items_change_profile():
assert not statsheet.item_is_modeled(ITEMS["unmodeled"])
def test_on_attack_spell_scales_with_attack_speed():
# Pro-Attacke-Passive: DPS = Schaden × AS, unabhängig vom Mana-Zyklus.
spell = [0, 50, 75, 110, 0, 0, 0]
u = unit(spell=spell)
u["spell_on_attack"] = True
on_attack = statsheet.unit_stats(u, 1, [], ITEMS, None, None)
assert on_attack["spell_dps"] == pytest.approx(50 * 0.7)
cast = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None)
assert on_attack["spell_dps"] > cast["spell_dps"]
def test_frontline_casts_more():
spell = [0, 300, 450, 700, 0, 0, 0]
tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline")

View File

@@ -161,12 +161,16 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
dmg *= 1 + acc["ap_flat"] / 100
if scaling in ("ad", "both"):
dmg *= 1 + acc["ad_pct"]
frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0
cast_rate = min(
(as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap,
CAST_RATE_CAP,
)
spell_dps = dmg * cast_rate
if unit.get("spell_on_attack"):
# Passive: Bonus-Schaden pro Auto-Attacke, kein Mana-Zyklus.
spell_dps = dmg * as_eff
else:
frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0
cast_rate = min(
(as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap,
CAST_RATE_CAP,
)
spell_dps = dmg * cast_rate
# Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht
# vergleichbar (per-Hit vs. total). Relativer Deckel: max. 3× eigene
# Auto-DPS; das Populations-Perzentil dient als Floor für AD-lose Caster.

View File

@@ -1,6 +1,6 @@
"""Scripted policies playing full games headless — sanity check for the sim."""
from tft.sim import policy
from tft.sim import player, policy
from tft.sim.game import Game
@@ -15,6 +15,8 @@ def play_econ(game: Game) -> int:
while not game.over:
policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
game.round, params)
# Skript-Spieler stellt wie ein Bot auf (step tauscht für Menschen nicht).
player.fill_board(game.player, game.artifact)
game.step()
return game.placement

View File

@@ -54,6 +54,7 @@ class Game:
for p in self.players:
p.shop = [None] * cfg["shop"]["slots"]
self._maybe_offer_augment()
self._bots_act()
# ---- helpers ----
@@ -81,6 +82,15 @@ class Game:
if p is self.player:
self.log.append(f"{self.round['label']}: Carousel")
def _bots_act(self) -> None:
"""Bots ziehen beim Rundenstart — ihre Boards und Scores stehen damit
für die ganze Planungsphase fest und kämpfen unverändert."""
for b in self.bots:
if b.alive:
policy.act(b, self.pool, self.artifact, self.cfg, self.rng,
self.round, policy.ARCHETYPES[b.archetype])
player.fill_board(b, self.artifact)
def _maybe_offer_augment(self) -> None:
if not self.round.get("augment"):
return
@@ -187,12 +197,10 @@ class Game:
self.pick_augment(self.rng.randrange(len(self.player.augment_offer)))
rnd = self.round
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)
# Bots haben schon beim Rundenstart gezogen (_bots_act).
# swap=False: die manuelle Aufstellung des Spielers bleibt unangetastet.
if self.player.alive:
player.fill_board(self.player, self.artifact, swap=False)
results = self._resolve(rnd)
self._check_eliminations()
@@ -308,6 +316,7 @@ class Game:
continue
player.refresh_shop(p, self.pool, self.cfg, self.rng)
self._maybe_offer_augment()
self._bots_act()
def _finish_by_hp(self) -> None:
standings = sorted(self._alive(), key=lambda p: -p.hp)

View File

@@ -194,11 +194,15 @@ def grab_carousel_unit(p: PlayerState, pool: Pool, rng: random.Random) -> None:
merge(p, api, 1)
def fill_board(p: PlayerState, artifact: dict) -> None:
"""Freie Board-Plätze auffüllen und stärkere Bank-Units einwechseln."""
def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None:
"""Freie Board-Plätze auffüllen; mit swap auch stärkere Bank-Units einwechseln.
swap=False für den Menschen: seine Aufstellung wird nie überstimmt."""
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))
if not swap:
return
for i, u in enumerate(p.board):
if not p.bench:
break

View File

@@ -18,6 +18,10 @@ DAMAGE_FALLBACKS = (
)
TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"}
# "Attacks deal <tag>@X@" (Markup dazwischen erlaubt) — Schaden pro Auto-Attacke.
ATTACK_CTX_RE = re.compile(r"attacks deal\s*(?:<[^>]+>\s*)*$", re.I)
ATTACK_VAR_RE = re.compile(r"OnAttack|ToAttack|PerAttack")
# Augment-Stufe steckt im Icon-Dateinamen: _I / _II / _III.
AUGMENT_TIER_RE = re.compile(r"[-_](I{1,3})\.", re.I)
PLACEHOLDER_RE = re.compile(r"@([A-Za-z0-9_]+)(?:\*([\d.]+))?@")
@@ -62,12 +66,18 @@ def resolve_spell(ability: dict) -> dict:
dmg_type = None
array = None
on_attack = False
for m in DAMAGE_TAG_RE.finditer(desc):
for vm in VAR_RE.finditer(m.group(2)):
name = vm.group(1)
array = get(name) or get(name.removeprefix("Modified"))
if array:
dmg_type = TAG_TO_TYPE[m.group(1)]
# Schaden pro Auto-Attacke statt pro Cast (z.B. Diana, Teemo):
# erkennbar am Variablennamen oder an "Attacks deal" vorm Tag.
context = desc[max(0, m.start() - 60):m.start()]
on_attack = bool(ATTACK_VAR_RE.search(name)
or ATTACK_CTX_RE.search(context))
break
if array:
break
@@ -80,6 +90,7 @@ def resolve_spell(ability: dict) -> dict:
for name in DAMAGE_FALLBACKS:
array = get(name)
if array:
on_attack = bool(ATTACK_VAR_RE.search(name))
break
has_ap = "%i:scaleAP%" in desc
@@ -90,6 +101,7 @@ def resolve_spell(ability: dict) -> dict:
"spell_damage": array,
"spell_damage_type": dmg_type or ("magic" if array else None),
"spell_scaling": scaling,
"spell_on_attack": on_attack,
}