update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user