207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
"""Kampf: Minderung, Reichweite, Bewegung, Zaubern, Determinismus."""
|
|
|
|
import random
|
|
|
|
from tft.sim import battle as B
|
|
from tft.sim import hexgrid as H
|
|
|
|
UNITS = {
|
|
"NAH": {"stats": {"hp": 1000, "armor": 0, "magicResist": 0, "damage": 100,
|
|
"damageByStar": [0, 100, 200, 400], "attackSpeed": 1.0,
|
|
"range": 1, "mana": 0, "initialMana": 0,
|
|
"critChance": 0.0, "critMultiplier": 1.4}},
|
|
"FERN": {"stats": {"hp": 500, "armor": 0, "magicResist": 0, "damage": 50,
|
|
"damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0,
|
|
"range": 4, "mana": 0, "initialMana": 0,
|
|
"critChance": 0.0, "critMultiplier": 1.4}},
|
|
"PANZER": {"stats": {"hp": 1000, "armor": 100, "magicResist": 100,
|
|
"damage": 10, "damageByStar": [0, 10, 20, 40],
|
|
"attackSpeed": 1.0, "range": 1, "mana": 0,
|
|
"initialMana": 0, "critChance": 0.0,
|
|
"critMultiplier": 1.4}},
|
|
"MAGIER": {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10,
|
|
"damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0,
|
|
"range": 4, "mana": 30, "initialMana": 0,
|
|
"critChance": 0.0, "critMultiplier": 1.4},
|
|
"spell_damage": [0, 300, 600, 1200],
|
|
"spell_damage_type": "magic"},
|
|
}
|
|
|
|
|
|
def make(key, side, col, row, stars=1):
|
|
f = B.to_fighter({"api_name": key, "stars": stars, "col": col, "row": row},
|
|
side, UNITS)
|
|
assert f is not None
|
|
return f
|
|
|
|
|
|
def test_mitigation_halves_at_hundred_armor():
|
|
assert B.mitigate(100, 0) == 100
|
|
assert B.mitigate(100, 100) == 50
|
|
assert B.mitigate(100, 300) == 25
|
|
|
|
|
|
def test_stars_raise_health_and_damage():
|
|
one, two = make("NAH", "a", 0, 0, 1), make("NAH", "a", 0, 0, 2)
|
|
assert two.max_hp > one.max_hp
|
|
assert two.attack_damage == 2 * one.attack_damage
|
|
|
|
|
|
def test_the_stronger_side_wins():
|
|
a = [make("NAH", "a", i, 3) for i in range(3)]
|
|
b = [make("NAH", "b", i, 4) for i in range(1)]
|
|
assert B.fight(a, b, random.Random(1)).winner == "a"
|
|
|
|
|
|
def test_armour_buys_time():
|
|
"""Gleiches Leben, aber einer mindert — der hält doppelt so lange durch."""
|
|
def survives(defender):
|
|
a = [make(defender, "a", 3, 3)]
|
|
b = [make("NAH", "b", 3, 4)]
|
|
return B.fight(a, b, random.Random(1), max_seconds=120).seconds
|
|
|
|
assert survives("PANZER") > 1.8 * survives("NAH")
|
|
|
|
|
|
def test_ranged_units_close_the_gap_and_shoot_first():
|
|
"""Der Fernkämpfer steht außer Reichweite und muss trotzdem treffen."""
|
|
a = [make("FERN", "a", 3, 0)]
|
|
b = [make("NAH", "b", 3, 7)]
|
|
res = B.fight(a, b, random.Random(1), max_seconds=60)
|
|
# Beide bewegen sich; der Fernkämpfer trifft aus vier Feldern.
|
|
assert res.fighters[0].damage_done > 0
|
|
|
|
|
|
def test_units_move_towards_the_enemy():
|
|
one = make("NAH", "a", 3, 0)
|
|
foe = make("NAH", "b", 3, 7)
|
|
before = H.distance(one.pos, foe.pos)
|
|
B._step_towards(one, foe, {one.pos, foe.pos})
|
|
assert H.distance(one.pos, foe.pos) == before - 1
|
|
|
|
|
|
def test_occupied_cells_are_not_entered():
|
|
one = make("NAH", "a", 3, 3)
|
|
foe = make("NAH", "b", 3, 4)
|
|
taken = {one.pos, foe.pos} | set(H.neighbours(3, 3))
|
|
B._step_towards(one, foe, taken)
|
|
assert one.pos == (3, 3)
|
|
|
|
|
|
def test_mana_fills_from_attacks_and_then_casts():
|
|
caster = make("MAGIER", "a", 3, 3)
|
|
foe = make("NAH", "b", 3, 4)
|
|
assert caster.mana == 0
|
|
B.fight([caster], [foe], random.Random(1), max_seconds=10)
|
|
assert caster.casts >= 1
|
|
|
|
|
|
def test_units_without_mana_never_cast():
|
|
"""NAH hat keine Mana-Kosten — sonst zauberte er im ersten Tick."""
|
|
one = make("NAH", "a", 3, 3)
|
|
foe = make("PANZER", "b", 3, 4)
|
|
B.fight([one], [foe], random.Random(1), max_seconds=10)
|
|
assert one.casts == 0
|
|
|
|
|
|
def test_same_seed_same_outcome():
|
|
def run(seed):
|
|
a = [make("NAH", "a", i, 3) for i in range(3)]
|
|
b = [make("FERN", "b", i, 4) for i in range(3)]
|
|
res = B.fight(a, b, random.Random(seed))
|
|
return res.winner, res.seconds, len(res.survivors_a)
|
|
|
|
assert run(7) == run(7)
|
|
|
|
|
|
def test_a_fight_always_ends():
|
|
"""Zwei Panzer ohne Schaden dürfen nicht ewig laufen."""
|
|
a = [make("PANZER", "a", 3, 3)]
|
|
b = [make("PANZER", "b", 3, 4)]
|
|
res = B.fight(a, b, random.Random(1), max_seconds=5)
|
|
assert res.seconds <= 5
|
|
|
|
|
|
def test_unknown_units_are_skipped():
|
|
assert B.to_fighter({"api_name": "GIBTS_NICHT"}, "a", UNITS) is None
|
|
|
|
|
|
AOE = {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10,
|
|
"damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0,
|
|
"range": 4, "mana": 30, "initialMana": 30,
|
|
"critChance": 0.0, "critMultiplier": 1.4},
|
|
"spell_damage": [0, 300, 600, 1200], "spell_damage_type": "magic",
|
|
"ability_vars": {"AoEHexRange": [0, 2, 2, 2]}}
|
|
|
|
|
|
def test_area_spells_hit_the_neighbours():
|
|
"""Radius aus den Fähigkeits-Variablen: alle im Umkreis nehmen Schaden."""
|
|
UNITS["FLAECHE"] = AOE
|
|
try:
|
|
caster = make("FLAECHE", "a", 3, 3)
|
|
near = [make("NAH", "b", c, 4) for c in (2, 3, 4)]
|
|
B.fight([caster], near, random.Random(1), max_seconds=1)
|
|
assert all(f.hp < f.max_hp for f in near)
|
|
finally:
|
|
del UNITS["FLAECHE"]
|
|
|
|
|
|
def test_single_target_spells_stay_single():
|
|
caster = make("MAGIER", "a", 3, 3)
|
|
near = [make("NAH", "b", c, 4) for c in (2, 3, 4)]
|
|
B.fight([caster], near, random.Random(1), max_seconds=1)
|
|
assert sum(1 for f in near if f.hp < f.max_hp) == 1
|
|
|
|
|
|
def test_spell_shape_reads_radius_and_count():
|
|
assert B.spell_shape(AOE, 2) == (2, 1)
|
|
assert B.spell_shape({"ability_vars": {"NumEnemiesHit": [0, 4, 4, 4]}}, 2) \
|
|
== (0, 4)
|
|
assert B.spell_shape({}, 2) == (0, 1)
|
|
|
|
|
|
def test_physical_spells_scale_with_attack_damage():
|
|
"""Ein Carry mit AD-Items soll auch härter zaubern."""
|
|
UNITS["AD_ZAUBER"] = {
|
|
"stats": {**UNITS["MAGIER"]["stats"], "initialMana": 30},
|
|
"spell_damage": [0, 300, 600, 1200], "spell_damage_type": "physical"}
|
|
ITEMS = {"AD": {"effects": {"AD": 1.0}}}
|
|
try:
|
|
plain = make("AD_ZAUBER", "a", 3, 3)
|
|
armed = make("AD_ZAUBER", "a", 3, 3)
|
|
B.apply_items(armed, ["AD"], ITEMS)
|
|
foes = [make("PANZER", "b", 3, 4), make("PANZER", "b", 3, 4)]
|
|
B.fight([plain], [foes[0]], random.Random(1), max_seconds=0.5)
|
|
B.fight([armed], [foes[1]], random.Random(1), max_seconds=0.5)
|
|
assert armed.damage_done > 1.5 * plain.damage_done
|
|
finally:
|
|
del UNITS["AD_ZAUBER"]
|
|
|
|
|
|
def test_the_log_records_spawn_hit_and_death():
|
|
a = [make("NAH", "a", 3, 3)]
|
|
b = [make("FERN", "b", 3, 4)]
|
|
res = B.fight(a, b, random.Random(1), max_seconds=30, record=True)
|
|
kinds = {e["e"] for e in res.events}
|
|
assert {"spawn", "hit", "die"} <= kinds
|
|
# Zwei Kämpfer, zwei Spawns, und jeder Eintrag trägt einen Tick.
|
|
assert sum(1 for e in res.events if e["e"] == "spawn") == 2
|
|
assert all("t" in e for e in res.events)
|
|
|
|
|
|
def test_the_log_stays_off_by_default():
|
|
"""Die Auswertung läuft millionenfach — dort darf nichts mitgeschrieben werden."""
|
|
a = [make("NAH", "a", 3, 3)]
|
|
b = [make("NAH", "b", 3, 4)]
|
|
assert B.fight(a, b, random.Random(1), max_seconds=5).events == []
|
|
|
|
|
|
def test_recording_does_not_change_the_outcome():
|
|
def run(record):
|
|
a = [make("NAH", "a", i, 3) for i in range(3)]
|
|
b = [make("FERN", "b", i, 4) for i in range(3)]
|
|
res = B.fight(a, b, random.Random(5), record=record)
|
|
return res.winner, res.seconds
|
|
|
|
assert run(False) == run(True)
|