extends SceneTree ## Kopflose Prüfläufe für die Kernlogik. var failures := 0 func check(cond: bool, msg: String) -> void: if cond: print(" OK ", msg) else: failures += 1 print(" FAIL ", msg) func _initialize() -> void: test_hex() test_upgrade_chain() test_pool_shared() test_combat_runs() test_full_game() test_placements() test_trait_effect() test_buy_specialisation() test_own_shop() test_move_slot() test_interest() test_prices() test_pool_pressure() test_neutral_chance() test_level_xp() test_damage_scaling() test_heal_range() test_combat_hits() test_plan_timer() test_bots_reach_three_star() test_shop_lock() test_element_profiles() test_pairing_known_early() test_recap() test_bot_arrange() test_element_forms() test_difficulty() test_settings() test_healer_arrange() test_undo_sell() test_pve_rounds() test_stats() test_save_load() test_counters() test_bot_counter_pick() print("") if failures == 0: print("ALLE TESTS BESTANDEN") else: print("FEHLER: ", failures) quit(1 if failures > 0 else 0) func test_hex() -> void: print("[Hex]") check(Hex.distance(Vector2i(0, 0), Vector2i(0, 0)) == 0, "Distanz zu sich selbst = 0") check(Hex.distance(Vector2i(0, 0), Vector2i(1, 0)) == 1, "Nachbar rechts = 1") for n in Hex.neighbors(Vector2i(2, 2)): if Hex.distance(Vector2i(2, 2), n) != 1: check(false, "Nachbar hat Distanz 1: " + str(n)) return for n in Hex.neighbors(Vector2i(2, 3)): if Hex.distance(Vector2i(2, 3), n) != 1: check(false, "Nachbar (ungerade Zeile) hat Distanz 1: " + str(n)) return check(true, "alle 6 Nachbarn haben Distanz 1 (gerade und ungerade Zeile)") func test_upgrade_chain() -> void: print("[Aufwerten]") var pool := Pool.new() var p := PlayerState.new(0, "Test", false) p.gold = 100 # Beim Kauf gewählter Feuer-Avatar wird aufgestellt. p.bench[0] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) var ok := p.place(0, Vector2i(3, 1)) check(ok, "Feuer-Avatar von der Bank aufstellen") check(p.unlocked[Data.Element.FEUER], "Feuer erscheint danach im eigenen Shop") var hex := Vector2i(3, 1) check(not p.can_upgrade(hex), "ohne Futter kein Upgrade") check(p.upgrade_missing(hex).get(Data.Element.FEUER, 0) == 2, "es fehlen 2 Feuer-Avatare") p.bench[0] = Avatar.new(Data.Role.TANK, [Data.Element.FEUER], 1) p.bench[1] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) check(p.can_upgrade(hex), "mit 2 Feuer-Avataren ist 2-Star möglich") check(p.upgrade(hex, Data.Element.WIND, pool), "Upgrade auf 2-Star mit Wind als 2. Element") var a: Avatar = p.board[hex] check(a.star == 2 and a.elements == [Data.Element.FEUER, Data.Element.WIND], "2-Star Feuer/Wind") check(p.bench[0] == null and p.bench[1] == null, "Futter wurde verbraucht") var miss := p.upgrade_missing(hex) check(miss.get(Data.Element.FEUER, 0) == 3 and miss.get(Data.Element.WIND, 0) == 3, "3-Star braucht 3 Feuer und 3 Wind") for i in 3: p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) for i in range(3, 6): p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.WIND], 1) check(p.can_upgrade(hex), "mit 3+3 Futter ist 3-Star möglich") check(p.upgrade(hex, Data.Element.BLITZ, pool), "Upgrade auf 3-Star mit Blitz") a = p.board[hex] check(a.star == 3 and a.elements.size() == 3, "3-Star mit drei Elementen") check(p.upgrade_cost().is_empty() if false else a.upgrade_cost().is_empty(), "3-Star ist Maximum") var s1 := Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1).base_stats() check(a.base_stats()["hp"] > s1["hp"] * 3.0, "Stats steigen deutlich pro Stern") func test_pool_shared() -> void: print("[Geteilter Pool]") var pool := Pool.new() var p := PlayerState.new(0, "Test", false) p.unlock(Data.Element.FEUER) var taken := 0 while pool.take(Data.Element.FEUER): taken += 1 check(taken == Data.POOL_PER_ELEMENT, "Pro Element nur %d Avatare insgesamt" % Data.POOL_PER_ELEMENT) check(p.shop_elements(pool).is_empty(), "leergekauftes Element verschwindet aus dem Shop") func test_combat_runs() -> void: print("[Kampf]") var pool := Pool.new() var a := PlayerState.new(0, "A", false) var b := PlayerState.new(1, "B", true) a.level = 4 b.level = 4 for i in 4: a.bench[i] = Avatar.new(i % 3, [Data.Element.FEUER] as Array[int], 1) a.place(i, Vector2i(2 + i, i % 2)) for i in 4: b.bench[i] = Avatar.new(i % 3, [Data.Element.EIS] as Array[int], 1) b.place(i, Vector2i(2 + i, i % 2)) var c := Combat.new(a, b, 12345) check(c.units.size() == 8, "8 Einheiten in der Arena") var team0 := 0 for u in c.units: if u.team == 0: team0 += 1 check(team0 == 4, "4 Einheiten pro Team") c.run_to_end() check(c.finished, "Kampf endet") check(c.winner in [-1, 0, 1], "Ergebnis ist gültig") print(" Sieger: ", c.winner, " Überlebende: ", c.survivors, " Dauer: %.1fs" % c.time) # Trait-Wirkung: Feuer 4 muss den Angriff erhöhen. var counts := a.trait_counts() check(counts.get(Data.Element.FEUER, 0) == 4, "4 Feuer-Einheiten aufgestellt") check(Data.trait_tier(Data.Element.FEUER, 4) == 2, "Feuer-Trait auf Stufe 2") func test_full_game() -> void: print("[Kompletter Loop]") var g := Game.new() g.start(999) check(g.players.size() == 8, "8 Spieler (du + 7 Bots)") check(g.human.hp == 100 and g.human.gold == Data.START_GOLD, "Start: 100 Leben, %d Gold" % Data.START_GOLD) check(g.human.shop.size() == Data.SHOP_SLOTS, "Shop hat %d Plätze" % Data.SHOP_SLOTS) var only_neutral := true for e in g.human.shop: if e.element >= 0: only_neutral = false check(true, "Shop-Angebot Runde 1 (nur neutral: %s)" % str(only_neutral)) # Menschlichen Spieler wie einen Bot spielen lassen, damit der Loop läuft. var ai := BotAI.new() ai.setup(g.rand) var guard := 0 while g.phase != Game.Phase.GAMEOVER and guard < 200: ai.take_turn(g.human, g.pool, g.rand) g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() guard += 1 check(g.phase == Game.Phase.GAMEOVER, "Spiel endet (Runden gespielt: %d)" % g.round_no) var alive := g.alive_players() check(alive.size() == 1, "genau ein Gewinner") if alive.size() == 1: print(" Sieger: ", alive[0].pname, " mit ", alive[0].hp, " Leben") var sum_hp := 0 for p in g.players: sum_hp += p.hp check(sum_hp < 8 * Data.START_HP, "Leben wurden verloren") var any_upgraded := false var max_star := 1 for p in g.players: for u in p.board_units(): max_star = maxi(max_star, u.star) check(max_star >= 2, "mindestens ein Avatar wurde aufgewertet (max %d-Star)" % max_star) var used := 0 for e in Data.ELEMENT_COUNT: used += Data.POOL_PER_ELEMENT - g.pool.element_left[e] check(used > 0, "Bots verbrauchen den geteilten Pool (%d Avatare gekauft)" % used) func test_placements() -> void: print("[Platzierungen]") var g := Game.new() g.start(4242) var ai := BotAI.new() ai.setup(g.rand) var guard := 0 while g.phase != Game.Phase.GAMEOVER and guard < 200: if g.human.alive: ai.take_turn(g.human, g.pool, g.rand) g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() guard += 1 var seen := {} var ok := true for p in g.players: if p.placement < 1 or p.placement > 8 or seen.has(p.placement): ok = false print(" ", p.pname, " Platz ", p.placement, " hp ", p.hp) seen[p.placement] = true check(ok, "jeder Spieler hat genau einen Platz 1..8") check(seen.size() == 8, "acht verschiedene Plätze vergeben") func test_trait_effect() -> void: print("[Trait-Wirkung]") var pool := Pool.new() # Team mit 4x Feuer -> Trait Stufe 2 (+26% Angriff). var strong := PlayerState.new(0, "Feuer4", false) for i in 4: strong.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) # Team mit nur 1x Feuer -> kein Trait. var weak := PlayerState.new(1, "Gemischt", false) weak.board[Vector2i(0, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) for i in range(1, 4): weak.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [i], 1) var c := Combat.new(strong, weak, 777) var atk_strong := 0.0 var atk_weak := 0.0 for u in c.units: if u.avatar.has_element(Data.Element.FEUER): if u.team == 0: atk_strong = u.atk else: atk_weak = u.atk check(atk_strong > atk_weak * 1.2, "Feuer-Trait erhöht den Angriff (%d statt %d)" % [int(atk_strong), int(atk_weak)]) # Kombi-Trait: Feuer 2 + Wind 2 muss Feuersturm auslösen. var combo := PlayerState.new(2, "Kombi", false) for i in 2: combo.board[Vector2i(i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) combo.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND], 1) var keys := [] for x in combo.active_combos(): keys.append(x["key"]) check(keys.has("feuersturm"), "Feuer 2 + Wind 2 aktiviert Feuersturm") check(not keys.has("frost"), "Wasser/Eis bleibt ohne die Elemente inaktiv") # Erde-Trait muss die Lebenspunkte im Kampf erhöhen. var earth := PlayerState.new(3, "Erde4", false) for i in 4: earth.board[Vector2i(i, 1)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE], 1) var c2 := Combat.new(earth, weak, 778) var base_hp: float = Avatar.new(Data.Role.TANK, [Data.Element.ERDE], 1).base_stats()["hp"] var got := 0.0 for u in c2.units: if u.team == 0: got = u.max_hp check(got > base_hp * 1.2, "Erde-Trait erhöht Leben (%d statt %d)" % [int(got), int(base_hp)]) func test_buy_specialisation() -> void: print("[Spezialisierung beim Kauf]") var pool := Pool.new() var p := PlayerState.new(0, "Test", false) p.gold = 20 p.shop = [ShopEntry.new(Data.Role.TANK, -1)] check(p.shop[0].element < 0, "Shop bietet einen neutralen Avatar an") check(not p.buy(0, pool, -1), "Kauf ohne gewähltes Element wird abgelehnt") var before: int = pool.element_left[Data.Element.BLITZ] check(p.buy(0, pool, Data.Element.BLITZ), "Kauf mit Blitz als Spezialisierung") var a: Avatar = p.bench[0] check(a != null and a.elements == [Data.Element.BLITZ], "Avatar liegt als Blitz auf der Bank") check(pool.element_left[Data.Element.BLITZ] == before - 1, "der Kauf zieht einen Blitz aus dem geteilten Vorrat (%d -> %d)" % [before, pool.element_left[Data.Element.BLITZ]]) check(not p.unlocked[Data.Element.BLITZ], "gekauft allein schaltet den Shop noch nicht frei") p.place(0, Vector2i(3, 1)) check(p.unlocked[Data.Element.BLITZ], "erst das Aufstellen schaltet Blitz im Shop frei") func test_own_shop() -> void: print("[Shop zeigt nur eigene Elemente]") var pool := Pool.new() var rng := RandomNumberGenerator.new() rng.seed = 7 var me := PlayerState.new(0, "Ich", false) var other := PlayerState.new(1, "Bot", true) other.unlock(Data.Element.EIS) me.unlock(Data.Element.FEUER) var mine := me.shop_elements(pool) var theirs := other.shop_elements(pool) check(mine.size() == 1 and mine[0] == Data.Element.FEUER, "eigener Shop kennt nur Feuer") check(theirs.size() == 1 and theirs[0] == Data.Element.EIS, "der Bot sieht nur sein eigenes Eis") var seen := {} for i in 200: me.roll_shop(pool, rng) for e in me.shop: seen[e.element] = true check(not seen.has(Data.Element.EIS), "fremdes Eis taucht im eigenen Shop nicht auf") check(seen.has(Data.Element.FEUER) and seen.has(-1), "eigener Shop zeigt Feuer und Neutral") func test_move_slot() -> void: print("[Verschieben zwischen Bank und Board]") var p := PlayerState.new(0, "Test", false) var f := Avatar.new(Data.Role.TANK, [Data.Element.FEUER] as Array[int], 1) var w := Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) p.bench[0] = f p.bench[1] = w var b0 := {"kind": "bench", "index": 0} var b1 := {"kind": "bench", "index": 1} var b3 := {"kind": "bench", "index": 3} var h1 := {"kind": "board", "hex": Vector2i(2, 1)} var h2 := {"kind": "board", "hex": Vector2i(4, 2)} check(p.move_slot(b0, h1), "Bank -> Board") check(p.board.get(Vector2i(2, 1)) == f and p.bench[0] == null, "Feuer steht auf (2,1)") check(p.unlocked[Data.Element.FEUER], "Aufstellen schaltet Feuer frei") check(p.move_slot(h1, h2), "Board -> Board") check(p.board.get(Vector2i(4, 2)) == f and not p.board.has(Vector2i(2, 1)), "Feuer steht auf (4,2)") check(p.move_slot(b1, h2), "Bank auf ein belegtes Feld = Tausch") check(p.board.get(Vector2i(4, 2)) == w and p.bench[1] == f, "Wind steht, Feuer liegt auf der Bank") check(p.move_slot(h2, b3), "Board -> Bank") check(p.bench[3] == w and p.board.is_empty(), "Wind liegt wieder auf der Bank") check(p.move_slot(b3, b1), "Bank -> Bank ist ein Tausch") check(p.bench[1] == w and p.bench[3] == f, "beide haben die Plätze getauscht") # Kaderplätze bleiben durch das Level begrenzt. for i in 5: p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.EIS] as Array[int], 1) var placed := 0 for i in 5: if p.move_slot({"kind": "bench", "index": i}, {"kind": "board", "hex": Vector2i(i, 0)}): placed += 1 check(placed == 3 and p.board.size() == 3, "Level 3 lässt nur 3 Einheiten zu (%d)" % placed) check(not p.move_slot({"kind": "board", "hex": Vector2i(9, 9)}, b0), "Feld ausserhalb des Boards wird abgelehnt") func test_interest() -> void: print("[Zinsen]") check(Data.interest(0) == 0, "0 Gold -> 0 Zins") check(Data.interest(9) == 0, "9 Gold -> 0 Zins") check(Data.interest(10) == 1, "10 Gold -> 1 Zins") check(Data.interest(35) == 3, "35 Gold -> 3 Zins") check(Data.interest(49) == 4, "49 Gold -> 4 Zins") check(Data.interest(50) == 5, "50 Gold -> 5 Zins") check(Data.interest(200) == 5, "über 50 Gold bleibt es bei 5 Zins") var p := PlayerState.new(0, "Test", false) p.gold = 0 check(p.round_income() == Data.BASE_INCOME, "ohne Gold nur die 5 Grundgold") p.gold = 30 check(p.round_income() == Data.BASE_INCOME + 3, "30 Gold -> 5 + 3 Zins") p.gold = 50 p.register_result(true) check(p.round_income() == Data.BASE_INCOME + Data.WIN_GOLD + 5, "50 Gold mit Sieg -> 5 + 1 + 5 Zins") for i in 4: p.register_result(true) check(p.round_income() == Data.BASE_INCOME + Data.WIN_GOLD + 5 + Data.streak_gold(5), "Serie kommt oben drauf") func test_prices() -> void: print("[Preise]") check(Data.PRICE_NONE == 1, "neutraler Avatar kostet 1 Gold") check(Data.PRICE_ELEMENT == 2, "Element-Avatar kostet 2 Gold") var pool := Pool.new() var p := PlayerState.new(0, "Test", false) p.gold = 10 p.shop = [ShopEntry.new(Data.Role.TANK, -1), ShopEntry.new(Data.Role.DPS, Data.Element.EIS)] check(p.shop[0].price() == 1 and p.shop[1].price() == 2, "Shop rechnet mit 1 und 2 Gold") p.buy(0, pool, Data.Element.FEUER) check(p.gold == 9, "neutraler Kauf zieht 1 Gold ab") p.buy(1, pool, -1) check(p.gold == 7, "Element-Kauf zieht 2 Gold ab") p.sell_bench(0, pool) check(p.gold == 8, "Verkauf gibt den Kaufpreis zurück") func test_pool_pressure() -> void: print("[Vorrat hält eine ganze Partie]") var lowest := Data.POOL_PER_ELEMENT var early_dry := 0 for run in 4: var g := Game.new() g.start(4000 + run) var ai := BotAI.new() ai.setup(g.rand) var guard := 0 while g.phase != Game.Phase.GAMEOVER and guard < 200: if g.human.alive: ai.take_turn(g.human, g.pool, g.rand) g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() for e in Data.ELEMENT_COUNT: lowest = mini(lowest, g.pool.element_left[e]) if g.round_no <= 15: for e in Data.ELEMENT_COUNT: if g.pool.element_left[e] <= 0: early_dry += 1 guard += 1 check(early_dry == 0, "bis Runde 15 läuft kein Element leer") check(lowest < Data.POOL_PER_ELEMENT / 2, "der Vorrat wird spürbar verbraucht (Tiefstand %d von %d)" % [lowest, Data.POOL_PER_ELEMENT]) func test_neutral_chance() -> void: print("[Neutral-Chance sinkt je Element]") check(is_equal_approx(Data.neutral_chance(0), 1.0), "ohne eigene Elemente nur Neutral") check(is_equal_approx(Data.neutral_chance(1), 0.75), "1 Element -> 75% Neutral") check(is_equal_approx(Data.neutral_chance(2), 0.5625), "2 Elemente -> 56%") check(Data.neutral_chance(6) > 0.0, "Neutral verschwindet nie ganz (%.1f%%)" % (Data.neutral_chance(6) * 100.0)) var pool := Pool.new() var rng := RandomNumberGenerator.new() rng.seed = 99 var quotes := [] for count in range(0, 5): var p := PlayerState.new(0, "Test", false) for e in count: p.unlock(e) var neutral := 0 var total := 0 for i in 400: p.roll_shop(pool, rng) for entry in p.shop: total += 1 if entry.element < 0: neutral += 1 quotes.append(float(neutral) / float(total)) print(" gemessene Neutral-Quoten: ", quotes) check(is_equal_approx(quotes[0], 1.0), "0 Elemente: alles neutral") var falling := true for i in range(1, quotes.size()): if quotes[i] >= quotes[i - 1]: falling = false check(falling, "die Quote sinkt mit jedem weiteren Element") for i in quotes.size(): if absf(quotes[i] - Data.neutral_chance(i)) > 0.05: check(false, "Quote bei %d Elementen weicht ab (%.2f statt %.2f)" % [i, quotes[i], Data.neutral_chance(i)]) return check(true, "jede Quote trifft den erwarteten Wert auf 5 Prozentpunkte genau") func test_level_xp() -> void: print("[Level und XP]") var p := PlayerState.new(0, "Test", false) check(p.level == 3 and p.team_slots() == 3, "Start auf Level 3 = 3 Kaderplätze") p.gold = 3 check(not p.buy_xp(), "ohne 4 Gold kein XP-Kauf") p.gold = 8 check(p.buy_xp() and p.level == 4 and p.gold == 4, "4 Gold -> 4 XP -> Level 4") check(p.buy_xp() and p.level == 4 and p.xp == 4, "weitere 4 XP reichen für Level 5 noch nicht (4/8)") p.gain_xp(4) check(p.level == 5 and p.xp == 0, "Rundenerfahrung füllt auf Level 5") p.gain_xp(1000) check(p.level == Data.MAX_LEVEL and p.xp == 0, "Level ist bei %d gedeckelt" % Data.MAX_LEVEL) check(not p.can_buy_xp(), "auf Maximalstufe kein XP-Kauf mehr") var g := Game.new() g.start(11) var lvl := g.human.level g.start_combat() g.skip_combat() check(g.human.xp == Data.XP_PER_ROUND or g.human.level > lvl, "jede Runde gibt %d XP" % Data.XP_PER_ROUND) func test_damage_scaling() -> void: print("[Schaden steigt mit der Runde]") check(Data.round_base_damage(1) == 2, "Runde 1: Grundschaden 2") check(Data.round_base_damage(9) == 4, "Runde 9: Grundschaden 4") check(Data.round_base_damage(25) == 8, "Runde 25: Grundschaden 8") # Sternsumme der Überlebenden zählt mit. var pool := Pool.new() var strong := PlayerState.new(0, "A", false) for i in 4: strong.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 3) var weak := PlayerState.new(1, "B", false) weak.board[Vector2i(3, 1)] = Avatar.new(Data.Role.HEILER, [Data.Element.EIS] as Array[int], 1) var c := Combat.new(strong, weak, 5) c.run_to_end() check(c.winner == 0, "das starke Team gewinnt") check(c.survivor_stars >= 3 * c.survivors and c.survivors > 0, "Sternsumme der Überlebenden = %d bei %d Einheiten" % [c.survivor_stars, c.survivors]) func test_heal_range() -> void: print("[Heiler heilen nur in Reichweite]") var healer := PlayerState.new(0, "A", false) healer.board[Vector2i(0, 3)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1) healer.board[Vector2i(6, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) var foe := PlayerState.new(1, "B", false) foe.board[Vector2i(6, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1) var c := Combat.new(healer, foe, 3) var h: Combat.CUnit = null var tank: Combat.CUnit = null for u in c.units: if u.team == 0 and u.avatar.role == Data.Role.HEILER: h = u if u.team == 0 and u.avatar.role == Data.Role.TANK: tank = u check(Hex.distance(h.pos, tank.pos) > Data.HEAL_RANGE, "Tank steht außer Heilreichweite") var list := c._allies_by_missing_hp(0, h) check(list.size() == 1 and list[0] == h, "weit entfernter Tank wird nicht geheilt, Heiler heilt sich") tank.pos = Vector2i(1, 7) list = c._allies_by_missing_hp(0, h) check(list.size() == 2, "steht der Tank daneben, wird er mitgeheilt") func test_combat_hits() -> void: print("[Kampf meldet Treffer]") var a := PlayerState.new(0, "A", false) var b := PlayerState.new(1, "B", false) for i in 3: a.board[Vector2i(2 + i, 0)] = Avatar.new(i, [Data.Element.FEUER] as Array[int], 1) b.board[Vector2i(2 + i, 0)] = Avatar.new(i, [Data.Element.WIND] as Array[int], 1) var silent := Combat.new(a, b, 8) silent.run_to_end() check(silent.hits.is_empty(), "Bot-Kämpfe zeichnen nichts auf") var loud := Combat.new(a, b, 8) loud.record_hits = true for i in 300: loud.step(0.05) var kinds := {} for h in loud.hits: kinds[h["kind"]] = kinds.get(h["kind"], 0) + 1 check(kinds.get("dmg", 0) > 0, "Schadenstreffer werden gemeldet (%d)" % kinds.get("dmg", 0)) check(kinds.get("heal", 0) > 0, "Heilungen werden gemeldet (%d)" % kinds.get("heal", 0)) check(kinds.get("cast", 0) > 0, "Zauber werden gemeldet (%d)" % kinds.get("cast", 0)) func test_plan_timer() -> void: print("[Planungsuhr]") var g := Game.new() g.start(21) check(is_equal_approx(g.plan_left, Data.PLAN_SECONDS), "Runde startet mit %d s" % int(Data.PLAN_SECONDS)) check(not g.tick_planning(10.0) and g.phase == Game.Phase.PLANNING, "nach 10 s noch Planung") check(g.tick_planning(Data.PLAN_SECONDS), "Ablauf startet den Kampf von selbst") check(g.phase == Game.Phase.COMBAT, "Phase ist Kampf") g.skip_combat() check(is_equal_approx(g.plan_left, Data.PLAN_SECONDS), "neue Runde setzt die Uhr zurück") g.timer_enabled = false check(not g.tick_planning(999.0) and g.phase == Game.Phase.PLANNING, "abgeschaltete Uhr läuft nicht ab") func test_bots_reach_three_star() -> void: print("[Bots erreichen 3-Star]") var three := 0 var games := 4 for run in games: var g := Game.new() g.start(7000 + run) var ai := BotAI.new() ai.setup(g.rand) var guard := 0 var seen := {} while g.phase != Game.Phase.GAMEOVER and guard < 200: if g.human.alive: ai.take_turn(g.human, g.pool, g.rand) for p in g.players: for u in p.board_units(): if u.star == 3: seen[u.id] = true g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() guard += 1 three += seen.size() check(three > 0, "in %d Partien entstanden %d 3-Star-Avatare" % [games, three]) func test_shop_lock() -> void: print("[Shop einfrieren]") var g := Game.new() g.start(31) var p := g.human var sig := [] for e in p.shop: sig.append([e.role, e.element]) p.shop_locked = true g.start_combat() g.skip_combat() var same := p.shop.size() == sig.size() for i in p.shop.size(): if same and (p.shop[i].role != sig[i][0] or p.shop[i].element != sig[i][1]): same = false check(same, "eingefrorener Shop bleibt über den Rundenwechsel gleich") p.gold = 10 p.reroll(g.pool, g.rand) check(not p.shop_locked, "Würfeln hebt das Einfrieren auf") p.shop = [ShopEntry.new(0, -1)] p.gold = 5 p.buy(0, g.pool, Data.Element.ERDE) check(p.last_element == Data.Element.ERDE, "letzte Spezialisierung wird gemerkt") func test_element_profiles() -> void: print("[Elemente prägen die Werte]") var feuer := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1).base_stats() var erde := Avatar.new(Data.Role.DPS, [Data.Element.ERDE] as Array[int], 1).base_stats() var wind := Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1).base_stats() var blitz := Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1).base_stats() check(feuer["atk"] > erde["atk"] * 1.2, "Feuer schlägt härter als Erde (%d vs %d)" % [int(feuer["atk"]), int(erde["atk"])]) check(erde["hp"] > feuer["hp"] * 1.25, "Erde hält mehr aus als Feuer (%d vs %d)" % [int(erde["hp"]), int(feuer["hp"])]) check(wind["aspd"] > erde["aspd"] * 1.25, "Wind ist schneller als Erde") check(blitz["crit"] > 0.0 and feuer["crit"] == 0.0, "nur Blitz bringt eigene Krit-Chance") var a := PlayerState.new(0, "A", false) a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1) var b := PlayerState.new(1, "B", false) b.board[Vector2i(3, 1)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) var c := Combat.new(a, b, 2) var blitz_unit = null for u in c.units: if u.team == 0: blitz_unit = u check(blitz_unit.crit >= 0.15, "Krit-Profil kommt im Kampf an (%.2f)" % blitz_unit.crit) func test_pairing_known_early() -> void: print("[Gegner steht in der Planung fest]") var g := Game.new() g.start(41) var opp := g.human_opponent() check(opp != null and g.phase == Game.Phase.PLANNING, "in der Planung ist der Gegner bekannt: %s" % (opp.pname if opp else "-")) g.start_combat() var fought := g.human_opponent() check(fought == opp or (fought != null and fought.pname.begins_with(opp.pname)), "gekämpft wird gegen genau diesen Gegner") g.skip_combat() check(g.human_opponent() != null, "neue Runde, neuer Gegner bekannt") # Jeder Bot kennt ebenfalls seinen Gegner. var all_known := true for p in g.alive_players(): if g.opponent_of(p) == null: all_known = false check(all_known, "jeder Spieler hat einen Gegner") func test_recap() -> void: print("[Rückblick]") var g := Game.new() g.start(51) g.human.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 2) g.human.board[Vector2i(2, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) g.start_combat() g.skip_combat() var r := g.human_recap check(not r.is_empty(), "Rückblick liegt vor") check(r["total"] == 2 and r["alive"] <= 2, "zählt eigene Einheiten (%d, %d überlebt)" % [r["total"], r["alive"]]) check(r["opponent"] != "" and r.has("result") and r.has("damage"), "nennt Gegner, Ergebnis und Schaden") check(r["best_dealt"] > 0 or r["best_healed"] > 0, "stärkste Einheit hat etwas beigetragen (%s)" % r["best_name"]) func test_bot_arrange() -> void: print("[Bots stellen gegen den Gegner auf]") var ai := BotAI.new() var rng := RandomNumberGenerator.new() rng.seed = 3 ai.setup(rng) var me := PlayerState.new(1, "Bot", true) me.level = 6 for i in 2: me.board[Vector2i(i, 3)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) for i in 3: me.board[Vector2i(2 + i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) var opp := PlayerState.new(2, "Gegner", true) # Gegnerische Schützen links (aus seiner Sicht), also rechts aus unserer. for i in 3: opp.board[Vector2i(0, i)] = Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1) opp.board[Vector2i(6, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) ai.arrange(me, opp) check(me.board.size() == 5, "alle Einheiten stehen wieder (%d)" % me.board.size()) var tanks_front := true var dps_back := true var tank_cols := [] var dps_cols := [] for hex in me.board: var u: Avatar = me.board[hex] if u.role == Data.Role.TANK: tanks_front = tanks_front and hex.y == 0 tank_cols.append(hex.x) else: dps_back = dps_back and hex.y >= 2 dps_cols.append(hex.x) check(tanks_front, "Tanks stehen vorn") check(dps_back, "Schützen stehen hinten") check(tank_cols.has(6), "ein Tank steht vor den gegnerischen Schützen (Spalte 6)") var avg := 0.0 for c in dps_cols: avg += c avg /= dps_cols.size() check(avg > 3.0, "Schützen meiden die Seite des gegnerischen Tanks (Mittel %.1f)" % avg) func _duel(my_el: int, my_role: int, foes: Array) -> Combat: var a := PlayerState.new(0, "A", false) a.board[Vector2i(3, 0)] = Avatar.new(my_role, [my_el] as Array[int], 1) var b := PlayerState.new(1, "B", false) for i in foes.size(): b.board[foes[i]] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1) return Combat.new(a, b, 77) func _unit(c: Combat, team: int, index: int = 0) -> Combat.CUnit: var n := 0 for u in c.units: if u.team == team: if n == index: return u n += 1 return null func test_element_forms() -> void: print("[Element-Formen prägen die Fähigkeit]") # Feuer: Nachbarn des Ziels brennen mit. var c := _duel(Data.Element.FEUER, Data.Role.DPS, [Vector2i(3, 0), Vector2i(4, 0)]) var me := _unit(c, 0) var t1 := _unit(c, 1, 0) var t2 := _unit(c, 1, 1) me.target = t1 c._cast(me) check(t1.hp < t1.max_hp and t2.hp < t2.max_hp, "Flächenbrand trifft Ziel und Nachbar") check(t2.burn_timer > 0.0, "Nachbar brennt") # Erde: Gegner neben dem Ziel sind betäubt. c = _duel(Data.Element.ERDE, Data.Role.HEILER, [Vector2i(3, 0), Vector2i(4, 0)]) me = _unit(c, 0) t1 = _unit(c, 1, 0) t2 = _unit(c, 1, 1) me.target = t1 c._cast(me) check(t1.stun_timer > 0.0 and t2.stun_timer > 0.0, "Erschütterung betäubt Ziel und Nachbar, auch beim Heiler") # Eis: Frostfeld verlangsamt um das Ziel. c = _duel(Data.Element.EIS, Data.Role.TANK, [Vector2i(3, 0), Vector2i(4, 0)]) me = _unit(c, 0) t1 = _unit(c, 1, 0) t2 = _unit(c, 1, 1) me.target = t1 c._cast(me) check(t1.slow >= 0.3 and t2.slow >= 0.3, "Frostfeld verlangsamt beide, auch beim Tank") # Wasser und Wind wirken auf Verbündete im Umkreis. var a := PlayerState.new(0, "A", false) a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1) a.board[Vector2i(2, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) var b := PlayerState.new(1, "B", false) b.board[Vector2i(3, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) c = Combat.new(a, b, 5) var healer := _unit(c, 0, 0) var ally := _unit(c, 0, 1) if healer.avatar.role != Data.Role.HEILER: var tmp := healer healer = ally ally = tmp var mana0 := ally.mana c._cast(healer) check(ally.mana >= mana0 + 20.0 - 0.01, "Welle gibt dem Verbündeten 20 Mana") c._cast(ally) check(healer.haste >= 0.25, "Sturmschritt beschleunigt den Verbündeten") # Blitz: springt auf weitere Gegner. c = _duel(Data.Element.BLITZ, Data.Role.TANK, [Vector2i(3, 0), Vector2i(0, 3), Vector2i(6, 3)]) me = _unit(c, 0) me.target = _unit(c, 1, 0) c._cast(me) var hurt := 0 for u in c.units: if u.team == 1 and u.hp < u.max_hp: hurt += 1 check(hurt >= 2, "Kette springt vom Tank auf zwei weitere Gegner (%d getroffen)" % hurt) check(Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1).ability_text().contains("Form Flächenbrand"), "Fähigkeitstext nennt die Form") func test_difficulty() -> void: print("[Schwierigkeit]") var rng := RandomNumberGenerator.new() var greed := {} for d in 3: rng.seed = 5 var sum := 0.0 for i in 30: var ai := BotAI.new() ai.setup(rng, d) sum += ai.greed greed[d] = sum / 30.0 check(greed[Data.Difficulty.LEICHT] > greed[Data.Difficulty.NORMAL] and greed[Data.Difficulty.NORMAL] > greed[Data.Difficulty.SCHWER], "leicht hortet am meisten, schwer am wenigsten (%.2f / %.2f / %.2f)" % [greed[0], greed[1], greed[2]]) var g := Game.new() g.start(9, Data.Difficulty.SCHWER) check(g.difficulty == Data.Difficulty.SCHWER and g.bots.values()[0].difficulty == Data.Difficulty.SCHWER, "Spiel reicht die Schwierigkeit an die Bots durch") func test_settings() -> void: print("[Einstellungen speichern]") var path := "user://settings_test.cfg" var st := Settings.new() st.volume = 0.35 st.difficulty = Data.Difficulty.SCHWER st.save_to_disk(path) var back := Settings.new() back.load_from_disk(path) check(is_equal_approx(back.volume, 0.35) and back.difficulty == Data.Difficulty.SCHWER, "Lautstärke und Schwierigkeit kommen aus der Datei zurück") var fresh := Settings.new() fresh.load_from_disk("user://gibt_es_nicht.cfg") check(is_equal_approx(fresh.volume, 0.8) and fresh.difficulty == Data.Difficulty.NORMAL, "ohne Datei gelten die Standardwerte") DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) func test_healer_arrange() -> void: print("[Bot-Heiler stehen bei den Tanks]") var ai := BotAI.new() var rng := RandomNumberGenerator.new() rng.seed = 4 ai.setup(rng) var me := PlayerState.new(1, "Bot", true) me.level = 9 for i in 3: me.board[Vector2i(i, 3)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) for i in 2: me.board[Vector2i(3 + i, 3)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1) for i in 2: me.board[Vector2i(5 + i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) ai.arrange(me, null) var ok := true for hex in me.board: if me.board[hex].role != Data.Role.HEILER: continue var near := false for other in me.board: if me.board[other].role == Data.Role.TANK and Hex.distance(hex, other) <= Data.HEAL_RANGE: near = true ok = ok and near check(ok, "jeder Heiler erreicht einen Tank") func test_undo_sell() -> void: print("[Verkauf rückgängig]") var pool := Pool.new() var p := PlayerState.new(0, "T", false) p.gold = 5 var a := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) a.invested = 2 pool.take(Data.Element.FEUER) p.bench[2] = a var left := pool.element_left[Data.Element.FEUER] check(p.sell_bench(2, pool) and p.gold == 7 and pool.element_left[Data.Element.FEUER] == left + 1, "Verkauf gibt Gold und Vorrat zurück") check(p.undo_sell(pool), "Rückgängig geht") check(p.bench[2] == a and p.gold == 5 and pool.element_left[Data.Element.FEUER] == left, "Avatar liegt wieder da, Gold und Vorrat stimmen") check(not p.undo_sell(pool), "zweites Rückgängig ist wirkungslos") p.sell_bench(2, pool) p.gold = 0 check(not p.undo_sell(pool), "ohne Gold kein Rückgängig") func test_pve_rounds() -> void: print("[Wildnis-Runden zum Warmlaufen]") var g := Game.new() g.start(61) check(g.is_pve_round(), "Runde 1 ist eine Wildnis-Runde") var opp := g.human_opponent() check(opp != null and opp.pname == "Wildnis" and opp.board.size() == 1, "Gegner ist die Wildnis mit einer Einheit") check(g.ranking().size() == 8, "die Wildnis zählt nicht als Spieler") var hp0 := g.human.hp g.start_combat() g.skip_combat() check(g.human.hp <= hp0, "gegen die Wildnis kann man Leben verlieren") check(g.round_no == 2 and g.human_opponent().pname == "Wildnis" and g.human_opponent().board.size() == 2, "Runde 2: Wildnis mit zwei Einheiten") g.start_combat() g.skip_combat() check(not g.is_pve_round() and g.human_opponent().pname != "Wildnis", "ab Runde %d spielen die Spieler gegeneinander" % (Data.PVE_ROUNDS + 1)) var all_alive_before := g.alive_players().size() check(all_alive_before == 8, "niemand ist an der Wildnis gestorben") func test_stats() -> void: print("[Statistik]") var g := Game.new() g.start(71) var ai := BotAI.new() ai.setup(g.rand) for i in 5: ai.take_turn(g.human, g.pool, g.rand) g.start_combat() g.skip_combat() var st := g.human.stats check(st["wins"] + st["losses"] >= 4, "Siege und Niederlagen werden gezählt (%d/%d)" % [st["wins"], st["losses"]]) check(st["gold_earned"] >= 5 * 5, "verdientes Gold summiert (%d)" % st["gold_earned"]) check(st["spent"] > 0, "ausgegebenes Gold summiert (%d)" % st["spent"]) func test_save_load() -> void: print("[Speichern und Laden]") var path := "user://spielstand_test.json" var g := Game.new() g.start(81) var ai := BotAI.new() ai.setup(g.rand) for i in 4: ai.take_turn(g.human, g.pool, g.rand) g.start_combat() g.skip_combat() g.human.shop_locked = true check(g.save(path), "Spielstand geschrieben") check(Game.has_save(path), "Datei ist da") var l := Game.load_from(path) check(l != null, "Spielstand gelesen") check(l.round_no == g.round_no and l.human.gold == g.human.gold and l.human.hp == g.human.hp, "Runde, Gold, Leben stimmen") check(l.human.board.size() == g.human.board.size() and l.human.level == g.human.level, "Brett und Level stimmen") check(l.human.shop_locked and l.human.shop.size() == g.human.shop.size(), "Shop samt Einfrieren stimmt") var same_pool := true for e in Data.ELEMENT_COUNT: if l.pool.element_left[e] != g.pool.element_left[e]: same_pool = false check(same_pool, "geteilter Vorrat stimmt") check(l.players.size() == 8 and l.bots.size() == 7 and l.human_opponent() != null, "acht Spieler, sieben Bots, Gegner bekannt") var fired := 0 while l.phase != Game.Phase.GAMEOVER and fired < 60: l.start_combat() if l.phase == Game.Phase.COMBAT: l.skip_combat() fired += 1 check(l.phase == Game.Phase.GAMEOVER or fired == 60, "geladene Partie läuft weiter (%d Runden)" % fired) Game.delete_save(path) check(not Game.has_save(path), "Spielstand gelöscht") func test_counters() -> void: print("[Schere-Stein-Papier]") # Ring: jedes Element schlägt genau eines und wird von genau einem geschlagen. var beaten := {} for e in Data.ELEMENT_COUNT: var target: int = Data.ELEMENT_BEATS[e] check(target != e, "%s schlägt nicht sich selbst" % Data.ELEMENT_NAMES[e]) beaten[target] = beaten.get(target, 0) + 1 var ring_ok := beaten.size() == Data.ELEMENT_COUNT for e in beaten: ring_ok = ring_ok and beaten[e] == 1 check(ring_ok, "geschlossener Ring: jedes Element genau einmal geschlagen") check(Data.beats_element(Data.Element.WASSER, Data.Element.FEUER) and not Data.beats_element(Data.Element.FEUER, Data.Element.WASSER), "Wasser schlägt Feuer, nicht umgekehrt") check(Data.beaten_by(Data.Element.FEUER) == Data.Element.WASSER, "Feuer wird von Wasser geschlagen") check(Data.beats_role(Data.Role.TANK, Data.Role.DPS) and Data.beats_role(Data.Role.DPS, Data.Role.HEILER) and Data.beats_role(Data.Role.HEILER, Data.Role.TANK), "Rollen-Dreieck Tank > DPS > Heiler > Tank") check(is_equal_approx(Data.counter_multiplier(Data.Element.WASSER, Data.Role.TANK, Data.Element.FEUER, Data.Role.DPS), 1.35), "doppelter Konter gibt +35%") check(is_equal_approx(Data.counter_multiplier(Data.Element.FEUER, Data.Role.DPS, Data.Element.WASSER, Data.Role.TANK), 0.65), "doppelt gekontert gibt -35%") check(is_equal_approx(Data.counter_multiplier(Data.Element.FEUER, Data.Role.DPS, Data.Element.WIND, Data.Role.DPS), 1.0), "ohne Bezug bleibt es bei 100%") # Im Kampf: Wasser-Schütze gegen Feuer-Ziel trifft härter als gegen Wind-Ziel. var a := PlayerState.new(0, "A", false) a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WASSER] as Array[int], 1) var b := PlayerState.new(1, "B", false) b.board[Vector2i(3, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) var c := Combat.new(a, b, 3) c.record_hits = true var me: Combat.CUnit = c.units[0] if c.units[0].team == 0 else c.units[1] var foe: Combat.CUnit = c.units[1] if c.units[0].team == 0 else c.units[0] var hp0: float = foe.hp c._apply_damage(me, foe, 100.0, false) var dealt_vs_fire: float = hp0 - foe.hp check(is_equal_approx(dealt_vs_fire, 100.0 * 1.2 * (1.0 - foe.armor)), "Wasser gegen Feuer: +20% (%.0f)" % dealt_vs_fire) check(c.hits.size() == 1 and c.hits[0]["kind"] == "counter", "Konter-Treffer wird als solcher gemeldet") var b2 := PlayerState.new(1, "B", false) b2.board[Vector2i(3, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) var c2 := Combat.new(a, b2, 3) var me2: Combat.CUnit = c2.units[0] if c2.units[0].team == 0 else c2.units[1] var foe2: Combat.CUnit = c2.units[1] if c2.units[0].team == 0 else c2.units[0] var hp1: float = foe2.hp c2._apply_damage(me2, foe2, 100.0, false) check(is_equal_approx(hp1 - foe2.hp, 100.0 * (1.0 - foe2.armor)), "Wasser gegen Wind: normal") check(Avatar.new(Data.Role.TANK, [Data.Element.FEUER] as Array[int], 1).counter_text() == "Stark gegen Eis, DPS · Schwach gegen Wasser, Heiler", "Text nennt Stärken und Schwächen") func test_bot_counter_pick() -> void: print("[Bots kontern den Gegner]") var rng := RandomNumberGenerator.new() rng.seed = 12 var ai := BotAI.new() ai.setup(rng, Data.Difficulty.SCHWER) var me := PlayerState.new(1, "Bot", true) var opp := PlayerState.new(2, "Gegner", true) for i in 3: opp.board[Vector2i(i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) ai.opponent = opp check(ai._pick_element(me) == Data.Element.WASSER, "schwerer Bot wählt Wasser gegen ein Feuer-Team") ai.opponent = null check(ai.pref.has(ai._pick_element(me)), "ohne Gegner bleibt er bei seinen Vorlieben") var easy := BotAI.new() easy.setup(rng, Data.Difficulty.NORMAL) easy.pref = [Data.Element.ERDE, Data.Element.EIS, Data.Element.BLITZ] easy.opponent = opp check(easy._pick_element(me) != Data.Element.WASSER, "normaler Bot kontert nur innerhalb seiner Vorlieben") easy.pref = [Data.Element.WASSER, Data.Element.EIS, Data.Element.BLITZ] check(easy._pick_element(me) == Data.Element.WASSER, "… und nimmt den Konter, wenn er dazu passt")