Files
tft/backend/tests/test_battle.py
2026-09-07 07:44:09 +02:00

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)