extends SceneTree ## Regel-, KI- und Spielstand-Tests ohne Fenster: ## godot --headless --path . -s tests/test_runner.gd var passed := 0 var failed := 0 func check(cond: bool, msg: String) -> void: if cond: passed += 1 else: failed += 1 printerr("FEHLER: " + msg) func C(id: String) -> Cards.Card: return Cards.by_id(id).copy() ## Duell mit 10 Mana; der Gegner hat 15 Leben, kein Stapel nötig. Liefert [Spiel, Spieler, Gegner]. func setup(human_hand: Array = [], opp_hand: Array = []) -> Array: var human := Game.Player.new("Spieler", true, 30) var o := Game.Player.new("Gegner", false, 15) var r := RandomNumberGenerator.new() r.seed = 0 var g := Game.new(human, o, r) g.turn = 1 human.max_energy = 10 human.energy = 10 for cid in human_hand: human.hand.append(C(cid)) o.max_energy = 10 o.energy = 10 for cid in opp_hand: o.hand.append(C(cid)) return [g, human, o] var next_uid := 9000 func put(p: Game.Player, cid: String, ready: bool = true, hidden: bool = false) -> Game.Creature: next_uid += 1 var cr := Game.Creature.new(C(cid), p, next_uid) cr.attacks_left = cr.attacks_per_turn() if ready else 0 cr.hidden = hidden p.board.append(cr) return cr func _initialize() -> void: TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg test_kartenpool() test_kombos() test_mana_und_kosten() test_ziehen() test_wesen_und_angriff() test_elementvorteil() test_verdeckt() test_vier_elemente() test_verbessern() test_verschmelzen() test_limits() test_gleiche_regeln() test_zugreihenfolge() test_sieg_niederlage() test_booster() test_spielstand() test_kopie() test_ki_lethal() test_ki_verdeckt_unbekannt() test_ki_besser_als_gierig() test_ki_spiele_enden() test_spielstand_migration() test_kampagne() test_zufallssamen() test_spielstand_sicherung() test_merkmale() test_ki_zwei_schritte() test_snapshot() test_kapitel_zwei_und_merkmalwahl() test_ki_zeitbudget() test_englische_karten() test_kampf_speichern() test_uebersetzung_vollstaendig() print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) quit(1 if failed > 0 else 0) func test_kartenpool() -> void: check(Cards.all().size() == 77, "77 Karten") check(Cards.all().all(func(c): return c.elements.size() == 1 and c.elements[0] in Cards.ELEMENTS), "jede Karte hat genau ein Element") for e in Cards.ELEMENTS: check(Cards.all().any(func(c): return c.elements == [e]), "Element vorhanden: " + e) for c in Cards.all(): check(c.health > 0 and c.cost == ceili((c.attack + c.health) / 2.0), "Kosten = (Angriff + Leben) / 2: " + c.id) check(c.keywords.is_empty() and c.text() == "", "Karten kommen ohne Eigenschaften: " + c.id) check(C("f_kobold").cost == 2 and C("ff_phoenix").cost == 7 and C("e_wall").cost == 2, "Kostenbeispiele") var a := C("f_wolf") var b := C("f_wolf") a.keywords.append("Schild") check(b.keywords.is_empty() and Cards.by_id("f_wolf").keywords.is_empty(), "Exemplare teilen sich keine Eigenschaften") func test_kombos() -> void: check(Cards.combo_name(["Feuer", "Feuer"]) == "Inferno", "Feuer+Feuer = Inferno") check(Cards.combo_name(["Luft", "Feuer"]) == "Blitz", "Luft+Feuer = Blitz") check(not C("ff_phoenix").is_combo() and Cards.fusion_name(["Feuer"]) == "Inferno", "Kombos gibt es nur beim Verschmelzen") check(Cards.combo_name(["Feuer", "Wasser", "Erde"]) == "Chimäre", "drei Elemente sind eine Chimäre") check(Cards.beaten_by("Feuer") == "Wasser" and Cards.beaten_by("Erde") == "Luft", "wer wen schlägt") ## Mana: 5 im ersten Zug, jede Runde eins mehr, der Rest verfällt; Legen kostet die Kartenkosten. func test_mana_und_kosten() -> void: var s := setup(["f_wolf", "f_drache"]) var g: Game = s[0] var h: Game.Player = s[1] h.energy = 0 h.max_energy = 0 h.turns = 0 g.begin_turn(h) check(h.energy == Game.START_MANA and h.max_energy == Game.START_MANA, "erster Zug: 5 Mana") h.energy = 3 # Rest g.begin_turn(h) check(h.energy == Game.START_MANA + 1, "zweiter Zug: 6 Mana, der Rest ist verfallen") for i in 20: g.begin_turn(h) check(h.energy == Game.START_MANA + 21, "keine Obergrenze") h.energy = 5 check(g.play_card(h, h.hand[0]) == "" and h.energy == 5 - C("f_wolf").cost, "Ausspielen kostet die Kartenkosten") check(g.can_play(h, h.hand[0])[1] == "Nicht genug Mana", "Drache (6) zu teuer bei %d Mana" % h.energy) ## Ziehen: 1 Mana, drei vom gemeinsamen Stapel, eine behalten; der Rest unter den Stapel. func test_ziehen() -> void: var r := RandomNumberGenerator.new() r.seed = 1 var g := Game.new_battle(r) check(g.deck.size() == Cards.all().size() * DeckGen.COPIES - 2 * Game.START_OFFER, "Stapel: jede Karte dreimal, zehn schon ausgegeben") for p in g.players: check(p.offer.size() == Game.START_OFFER and p.picks_left == Game.START_PICKS and p.hand.is_empty(), "Start: fünf zur Wahl, drei zu nehmen") check(p.offer.all(func(c): return c.cost <= Game.START_MANA + p.start_bonus), "nur Karten, die im ersten Zug spielbar sind") var first: Game.Player = g.current() var deck_before := g.deck.size() var wanted: Cards.Card = first.offer[4] check(g.pick(first, 4) == "" and first.hand.size() == 1 and first.hand[0].id == wanted.id and first.hand[0] != wanted, "erste Startkarte genommen, als eigenes Exemplar") check(first.offer.size() == 4 and first.picks_left == 2, "vier bleiben, zwei noch zu nehmen") check(g.draw(first) == "Erst wählen" and g.can_play(first, first.hand[0])[1] == "Erst wählen", "bei offener Wahl geht nichts anderes") g.pick(first, 0) g.pick(first, 0) check(first.hand.size() == 3 and first.offer.is_empty() and g.deck.size() == deck_before + 2, "drei behalten, die anderen zwei liegen unter dem Stapel") var mana := first.energy check(g.draw(first) == "" and first.energy == mana - 1 and first.offer.size() == 3 and first.picks_left == 1, "gezogen: 1 Mana, drei zur Wahl") check(first.offer.all(func(c): return c.cost <= first.max_energy), "nur Karten bis zum Mana dieses Zugs") check(first.offer[0] != first.offer[1] and first.offer[1] != first.offer[2], "drei verschiedene Karten") check(g.pick(first, 1) == "" and first.hand.size() == 4 and first.offer.is_empty(), "eine genommen, Wahl zu") check(g.pick(first, 0) == "Keine Wahl offen", "ohne Wahl nichts zu nehmen") first.energy = 0 check(g.draw(first) == "Nicht genug Mana", "ohne Mana kein Ziehen") first.energy = 5 g.draw(first) g.pick(first, 0) check(first.hand.size() == Game.MAX_HAND and g.draw(first) == "Deine Hand ist voll", "fünf Handkarten sind das Limit") var second: Game.Player = g.enemy_of(first) check(g.pick(second, 0) == "" and second.hand.size() == 1, "wählen geht auch außerhalb des Zugs") g.end_turn() # jetzt ist der Zweite dran, seine Wahl ist noch offen g.end_turn() # sein Zugende nimmt den Rest von vorn check(second.hand.size() == 3 and second.offer.is_empty(), "Zugende nimmt offene Karten von vorn") # Leerer Stapel wird neu gemischt g.deck = [] var cur: Game.Player = g.current() cur.energy = 3 cur.hand.pop_back() # Platz auf der Hand check(g.draw(cur) == "" and cur.offer.size() == 3 and g.deck.size() > 0 and g.log.any(func(l): return l.begins_with("Der Stapel wird neu gemischt")), "leerer Stapel: neu gemischt") # Mit viel Mana tauchen auch teure Karten auf; zu teure wandern unter den Stapel g.pick(cur, 0) cur.max_energy = 20 cur.energy = 20 var expensive_before: int = g.deck.filter(func(c): return c.cost > 8).size() g.draw(cur) g.pick(cur, 0) var bottom: Array = g.deck.slice(0, 5) check(expensive_before > 0 and (g.deck.size() + 1 + cur.offer.size()) > 0 and bottom.size() == 5, "Stapel bleibt vollständig") func test_wesen_und_angriff() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] check(g.play_card(h, h.hand[0]) == "" and h.board.size() == 1 and h.board[0].can_attack(), "gespielt und sofort angriffsbereit") var wolf: Game.Creature = h.board[0] var e := put(o, "e_wall") # 0/4 check(g.can_attack(wolf, o)[1] == "Erst die Wesen", "Leben erst ohne Wesen angreifbar") check(g.attack(wolf, e) == "" and e.health == 4 - 3 and wolf.health == 2, "Angriff: Schaden, kein Konter von 0 Angriff") check(g.can_attack(wolf, e)[1].ends_with("kann nicht angreifen"), "nur ein Angriff je Zug") g.begin_turn(o) check(e.health == 4, "zu Zugbeginn erholt sich das Wesen seines Besitzers ganz") g.begin_turn(h) g.attack(wolf, e) check(e.health == 1 and o.board.size() == 1, "Angriff: wieder nur 3 Schaden") e.health = 1 g.begin_turn(h) g.attack(wolf, e) check(o.board.is_empty() and g.log.any(func(l): return l == "Steinwall stirbt."), "Wesen stirbt, Überschuss schlägt durch") g.begin_turn(h) var life0 := o.life check(g.attack(wolf, o) == "" and o.life == life0 - 3, "ohne Wesen trifft es den Helden") # Gegenschlag var troll := put(o, "e_troll") # 3/6 g.begin_turn(h) g.attack(wolf, troll) check(troll.health == 3 and wolf.health <= 0 and not (wolf in h.board), "Konter tötet den Wolf") # Durchschlag: was über das Leben des Wesens hinausgeht, trifft den Besitzer var drache := put(h, "f_drache") # 6/5 var kobold := put(o, "f_kobold") # 2/1 var life := o.life g.attack(drache, kobold) check(not (kobold in o.board) and o.life == life - 5 and g.log.any(func(l): return l == "5 Schaden schlagen durch auf Gegner."), "Durchschlag: 6 gegen 1 Leben, 5 gehen durch") var sch := put(o, "f_kobold") sch.shield = true life = o.life g.begin_turn(h) g.attack(drache, sch) check(sch.health == 1 and o.life == life, "Schild blockt alles, nichts schlägt durch") func test_elementvorteil() -> void: check(Cards.matchup(["Wasser"], ["Feuer"]) == 1 and Cards.matchup(["Feuer"], ["Wasser"]) == -1 and Cards.matchup(["Feuer"], ["Erde"]) == 0, "Kreis") var dampf := ["Feuer", "Wasser"] check(Cards.matchup(dampf, ["Feuer"]) == 1 and Cards.matchup(dampf, ["Luft"]) == 1, "zwei Elemente: Vorteil, wenn eines ihn hat") check(Cards.matchup(dampf, ["Wasser"]) == 0 and Cards.matchup(dampf, ["Erde"]) == 0, "zwei Elemente: nie im Nachteil gegen ein einzelnes") check(Cards.matchup(["Erde"], dampf) == 0 and Cards.matchup(["Wasser"], dampf) == 0, "auch als Verteidiger zählt das bessere Element") check(Cards.matchup(dampf, ["Wasser", "Erde"]) == -1 and Cards.matchup(["Wasser", "Erde"], dampf) == 1, "beide Elemente geschlagen: Nachteil") check(Cards.matchup(dampf, []) == 0 and Cards.matchup([], dampf) == 0, "unbekannte Elemente: kein Vorteil") var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] var tiefen := put(h, "w_tiefen") # Wasser 4/6 var wolf := put(o, "f_wolf") # Feuer 3/2 var life: int = o.life g.attack(tiefen, wolf) check(wolf.health <= 0 and o.life == life - 3 and tiefen.health == 6 - 3, "Wasser gegen Feuer: +1 beim Zuschlagen, der Konter bleibt gleich") var s3 := setup() var w3 := put(s3[1], "f_wolf") var t3 := put(s3[2], "w_tiefen") s3[0].attack(w3, t3) check(t3.health == 6 - 2 and w3.health <= 0, "Feuer gegen Wasser: 1 weniger Schaden, der Konter bleibt gleich") var s2 := setup() var g2: Game = s2[0] var geist := put(s2[1], "w_geist") # Wasser 2/2 g2.attack(geist, s2[2]) check(s2[2].life == 13, "gegen Helden gibt es keinen Vorteil") ## Verdeckt: greift nicht an, zählt nicht für die Elementregel, wird beim Angriff aufgedeckt. func test_verdeckt() -> void: var s := setup(["f_wolf", "f_kobold"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] var mana := h.energy check(g.play_card(h, h.hand[0], true) == "" and h.board[0].hidden and h.energy == mana - C("f_wolf").cost, "verdeckt gelegt, gleich teuer") var wolf: Game.Creature = h.board[0] check(g.log.back() == "Spieler legt ein Wesen verdeckt." and g.events.back().hidden, "Verlauf verrät die Karte nicht") g.begin_turn(h) check(not wolf.can_attack() and g.can_attack(wolf, o)[1] == "Flammenwolf liegt verdeckt", "verdeckt greift nicht an") check(g.reveal(h, wolf) == "" and not wolf.hidden and wolf.can_attack(), "aufgedeckt: kann sofort angreifen") check(g.reveal(h, wolf) != "", "offen bleibt offen") # Der Gegner deckt beim Angriff auf var kobold := put(o, "f_kobold", true, true) o.hand = [] check(g.attack(wolf, kobold) == "" and not kobold.hidden and kobold.health <= 0, "Angriff deckt auf und tötet") check(g.log.any(func(l): return l == "Feuerkobold wird aufgedeckt."), "Aufdecken steht im Verlauf") # Zuschauer und Gegner sehen die Karte nicht var hid := put(h, "f_asche", true, true) var oi := g.players.find(o) var snap := g.snapshot(oi) var mine: Dictionary = snap.players[g.players.find(h)] var cd: Dictionary = mine.board.filter(func(c): return int(c.uid) == hid.uid)[0] check(cd.hidden and cd.card is Dictionary and cd.card.id == "?" and int(cd.attack) == 0, "verdecktes Wesen reist ohne Karte und Werte") var masked := g.masked_clone(o) var seen := masked.find_creature(hid.uid) check(seen.card.id == "?" and seen.attack == 2, "die KI-Kopie kennt verdeckte Wesen nicht") var own: Dictionary = g.snapshot(g.players.find(h)).players[g.players.find(h)].board.filter(func(c): return int(c.uid) == hid.uid)[0] check(own.card == "f_asche", "der Besitzer sieht seine verdeckte Karte") ## Vier offene Wesen eines Elements: +1/+1 für das Element, -1/-1 für das, das es schlägt. func test_vier_elemente() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] var fire := [] for i in 3: fire.append(put(h, "f_wolf")) var bird := put(o, "l_wind") # Luft 1/1 g.apply_auras() check(fire[0].aura == 0 and bird.aura == 0, "drei reichen nicht") var hidden_fire := put(o, "f_kobold", true, true) g.apply_auras() check(fire[0].aura == 0, "verdeckte zählen nicht") check(g.element_counts()["Feuer"] == 3, "Zähler ohne Verdeckte") var fourth := put(o, "f_asche") # Feuer beim Gegner zählt auch g.apply_auras() check(fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 3 and fourth.aura == 1, "vier Feuer: +1/+1 für alle Feuerwesen") check(bird.aura == -1 and bird.power() == 0 and bird.health == 0, "Luft wird geschlagen: -1/-1") g.cleanup() check(not (bird in o.board), "das 1/1 ist daran gestorben") g.reveal(o, hidden_fire) if g.current() == o else g._flip(hidden_fire) check(hidden_fire.aura == 1, "aufgedeckt zählt es und bekommt den Bonus sofort") o.board.erase(fourth) o.board.erase(hidden_fire) g.apply_auras() check(fire[0].aura == 0 and fire[0].health == 2, "Bonus geht wieder, Leben folgt") # Vorschau check(g.forecast(h, C("f_kobold")) == 1 and g.forecast(h, C("l_wind")) == 0 and g.forecast(h, C("w_geist")) == 0, "Vorschau: Feuer würde den Stapel voll machen") # Merkmal: doppelt h.perks = ["element_doppelt"] put(h, "f_kobold") g.apply_auras() check(fire[0].aura == 2 and fire[0].power() == 5, "Elementbonus zählt doppelt") check(g.forecast(h, C("l_wind")) == -2, "Vorschau: Luft bekäme jetzt -2/-2") ## Verbessern: Eigenschaften für Mana, je Exemplar. func test_verbessern() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] h.energy = 20 var card: Cards.Card = h.hand[0] check(g.upgrade(h, card, "Schild") == "" and h.energy == 20 - 3 and "Schild" in h.hand[0].keywords, "Schild für die Handkarte gekauft") check(h.hand[0] != card and Cards.by_id("f_wolf").keywords.is_empty(), "eigenes Exemplar, der Pool bleibt sauber") check(g.upgrade(h, h.hand[0], "Schild") != "", "nicht doppelt") g.play_card(h, h.hand[0]) var wolf: Game.Creature = h.board[0] var after_play := h.energy check(wolf.shield and wolf.can_attack(), "mit Schild gelegt, greift sofort an") check(g.upgrade(h, wolf, "Doppelschlag") == "" and wolf.attacks_per_turn() == 2 and wolf.attacks_left == 2 and h.energy == after_play - 4, "Doppelschlag: zwei Angriffe") check(wolf.card.text() == "Schild, Doppelschlag.", "Kartentext zeigt die Eigenschaften") check(g.upgrade(h, wolf, "Doppelschlag") != "" and g.upgrade(h, put(o, "e_wall"), "Schild") != "" and g.upgrade(h, wolf, "Flink") != "", "nicht doppelt, nicht fremd, nicht unbekannt") h.energy = 2 var k2 := put(h, "f_kobold") check(g.upgrade(h, k2, "Schild") == "Nicht genug Mana", "zu teuer") h.perks = ["verbessern_billig"] check(g.upgrade(h, k2, "Schild") == "" and h.energy == 0, "Merkmal: Eigenschaften kosten 1 weniger") ## Verschmelzen: der bessere Wert von beidem plus 1, Kosten 1, 2, 4, 8 … func test_verschmelzen() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var a := put(h, "f_wolf") # 3/2 var b := put(h, "w_tiefen") # 4/6 b.health = 4 a.card.keywords.append("Schild") check(Game.fusion_cost(a, b) == 1, "erste Verschmelzung kostet 1") check(g.fuse(h, a, b) == "" and h.board.size() == 1 and h.energy == 9, "verschmolzen") var f: Game.Creature = h.board[0] check(f.name == "Dampf" and f.attack == 5 and f.max_health == 7 and f.health == 5, "Dampf mit dem besseren Angriff und Leben plus 1") check("Schild" in f.card.keywords and f.card.fusions == 1 and f.card.cost == Cards.cost_of(5, 7), "Eigenschaften geerbt, einmal verschmolzen") check(f.card.elements == ["Feuer", "Wasser"], "beide Elemente") var c := put(h, "e_wall") check(Game.fusion_cost(f, c) == 2, "zweite Verschmelzung kostet 2") g.fuse(h, f, c) var f2: Game.Creature = h.board[0] check(f2.name == "Chimäre" and f2.card.fusions == 2 and h.energy == 7, "Chimäre aus drei Elementen") var d := put(h, "l_wind") check(Game.fusion_cost(f2, d) == 4, "dritte kostet 4") h.energy = 3 check(g.fuse(h, f2, d) == "Nicht genug Mana", "zu teuer") check(g.can_fuse(h, f2, f2)[1] == "Zwei verschiedene Wesen wählen", "nicht mit sich selbst") # Verdeckte verschmelzen offen var s2 := setup() var x := put(s2[1], "f_wolf", true, true) var y := put(s2[1], "f_kobold", true, true) s2[0].fuse(s2[1], x, y) check(not s2[1].board[0].hidden, "das Ergebnis liegt offen") # Elementbonus der Eltern zählt nicht doppelt; Dampf nimmt den besseren Elementwert var s3 := setup() var g3: Game = s3[0] var h3: Game.Player = s3[1] var fa := put(h3, "f_wolf") var fb := put(h3, "w_tiefen") for i in 3: put(h3, "f_kobold") g3.apply_auras() check(fa.aura == 1 and fa.max_health == 3, "vier Feuer vor dem Verschmelzen") g3.fuse(h3, fa, fb) var ff: Game.Creature = h3.board[0] check(ff.aura == 1 and ff.max_health == 8 and ff.health == 8 and ff.power() == 6, "nach dem Verschmelzen zählt der Bonus nur einmal") for i in 3: put(h3, "w_geist") g3.apply_auras() check(g3.element_counts()["Wasser"] == 4 and ff.aura == 1, "Wasser +1 und Feuer -1: Dampf nimmt den besseren Wert") func test_limits() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] for i in Game.MAX_BOARD: put(h, "f_kobold") h.hand = [C("f_kobold")] check(g.can_play(h, h.hand[0])[1] == "Dein Feld ist voll", "Feld voll") h.hand = [] for i in Game.MAX_HAND: h.hand.append(C("f_kobold")) check(g.draw(h) == "Deine Hand ist voll" and h.hand.size() == Game.MAX_HAND, "volle Hand zieht nicht") check(g.play_card(h, C("f_kobold")) == "Nicht auf der Hand", "fremde Karte lässt sich nicht spielen") func test_gleiche_regeln() -> void: var r := RandomNumberGenerator.new() r.seed = 0 var g := Game.new_battle(r) var first: Game.Player = g.players[0] check(g.current() == first and first.max_energy == Game.START_MANA and first.energy == Game.START_MANA, "Startspieler: 5 Mana, kein Bonus") check(first.offer.size() == Game.START_OFFER and first.hand.is_empty(), "jeder beginnt mit einer Wahl aus fünf und leerer Hand") for p in g.players: check(p.life == Game.START_LIFE, "gleiches Leben") if p != first: check(p.start_bonus == 1, "Nachzieher: Bonus offen") g.end_turn() var second: Game.Player = g.players[1] check(g.current() == second and second.energy == Game.START_MANA + 1, "Nachzieher: 5 Mana plus Ausgleich") for i in 2: g.end_turn() check(g.current() == second and second.max_energy == Game.START_MANA + 1 and second.energy == Game.START_MANA + 1, "Ausgleich nur einmal, Rest verfällt") func test_zugreihenfolge() -> void: var starters := {} for seed in 12: var r := RandomNumberGenerator.new() r.seed = seed var g := Game.new_battle(r) starters[g.players[0].name] = true check(g.players.size() == 2 and g.human in g.players and g.opponent in g.players, "beide am Tisch") for i in g.players.size(): check(g.players[i].order == i, "Reihenfolge nummeriert") check(g.log[0].begins_with("Zugreihenfolge:"), "Reihenfolge im Verlauf") var g2 := g.clone() check(g2.players.map(func(p): return p.name) == g.players.map(func(p): return p.name), "Kopie behält Reihenfolge") check(starters.size() > 1, "Startspieler wechselt (%d verschiedene)" % starters.size()) func test_sieg_niederlage() -> void: var s := setup() var g: Game = s[0] g.damage(s[2], 14) g.check_end() check(g.winner == "", "noch offen") g.damage(s[2], 1) g.check_end() check(g.winner == "human" and g.winner_player == s[1], "Sieg") var s2 := setup() var g2: Game = s2[0] g2.damage(s2[1], 30) g2.check_end() check(g2.winner == "opponents", "Niederlage") ## Booster geben Seltenheitsstufen: höhere werten auf, der Rest wird zu Staub. func test_booster() -> void: var r := RandomNumberGenerator.new() r.seed = 3 for i in 50: var rolled := Profile.roll_booster(r) check(rolled.size() == 5, "5 Karten") check(rolled.any(func(x): return x.rarity != "gewöhnlich"), "mindestens eine seltene") var p := Profile.new() p.coins = Profile.BOOSTER_COST var got := p.open_booster(r) check(p.coins == 0 and got.size() == 5, "Booster kostet %d" % Profile.BOOSTER_COST) var upgraded := got.filter(func(x): return x.upgraded) check(upgraded.all(func(x): return p.rarity(x.card.id) == x.rarity), "aufgewertete Karten tragen ihre Stufe") check(p.dust == got.reduce(func(a, x): return a + int(x.dust), 0), "der Rest wird zu Staub") check(p.open_booster(r).is_empty(), "ohne Münzen kein Booster") p.dust = 100000 check(p.upgrade("f_wolf") == "" and p.level("f_wolf") >= 1, "aufgewertet") var lvl := p.level("f_wolf") check(p.dismantle("f_wolf") == "" and p.level("f_wolf") == lvl - 1, "eine Stufe abgegeben") p.collection["f_wolf"] = Cards.RARITIES.size() - 1 check(p.upgrade("f_wolf") != "", "über die höchste Stufe hinaus geht nicht") p.collection.erase("f_wolf") check(p.dismantle("f_wolf") != "" and p.rarity("f_wolf") == "gewöhnlich", "gewöhnliche Karten gehören allen") func test_spielstand() -> void: var path := "user://test_spielstand.json" var abs := ProjectSettings.globalize_path(path) for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) var p := Profile.load_or_new(path) check(p.coins == Profile.START_COINS and p.collection.is_empty(), "Starterprofil: alle Karten gewöhnlich") check(FileAccess.file_exists(path), "Datei angelegt") p.coins = 999 p.collection["f_wolf"] = 2 p.save(path) var p2 := Profile.load_or_new(path) check(p2.coins == 999 and p2.level("f_wolf") == 2, "geladen") for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) func test_kopie() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] var a := put(h, "f_asche") var g2 := g.clone() var a2 := g2.find_creature(a.uid) check(a2 != null and a2 != a and a2.owner() == g2.players[0], "Wesen kopiert mit neuem Besitzer") g2.attack(a2, g2.players[1]) check(g2.players[1].life == 11 and o.life == 15, "Kopie verändert das Original nicht") g2.play_card(g2.players[0], g2.players[0].hand[0]) check(h.hand.size() == 1 and g2.players[0].hand.is_empty(), "Hand der Kopie unabhängig") g2.upgrade(g2.players[0], a2, "Schild") check(not a.shield and a.card.keywords.is_empty(), "Verbessern in der Kopie lässt das Original unberührt") check(g2.log.is_empty() and g2.events.is_empty(), "Kopie schreibt keinen Verlauf") func test_ki_lethal() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] o.life = 3 var a := put(h, "f_asche") var act := AI.choose_action(g, h) check(act.type == "attack" and act.creature == a and act.target == o, "KI nutzt Lethal") ## Die KI schaut nicht in verdeckte Wesen: ihre Entscheidung ist dieselbe, egal was darunter liegt. func test_ki_verdeckt_unbekannt() -> void: var decisions := [] for cid in ["f_kobold", "ww_leviathan"]: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] put(o, "f_wolf") put(o, cid, true, true) var act := AI.choose_action(g, o) decisions.append(act.type + ":" + str(act.get("keyword", "")) + ":" + str(act.get("target", null) == h)) var own := AI.choose_action(g, h) check(own.type != "attack" or own.target == null or not (own.target is Game.Creature) or own.target.card.id != "?", "echte Aktion zeigt auf echte Wesen") check(decisions[0] == decisions[1], "gleiche Entscheidung bei Kobold und Leviathan unter der Karte (%s)" % [decisions]) func test_ki_besser_als_gierig() -> void: var r := RandomNumberGenerator.new() r.seed = 11 var smart := 0 var games := 40 for i in games: var g := Game.new_battle(r) var smart_is_human := i % 2 == 0 var guard := 0 while g.winner == "" and guard < 300: var p := g.current() AI.take_turn(g, p, p.is_human != smart_is_human) guard += 1 if (g.winner == "human") == smart_is_human: smart += 1 check(smart >= games / 2, "Vorausschau-KI ist mindestens so gut wie die gierige (%d von %d)" % [smart, games]) func test_ki_spiele_enden() -> void: var r := RandomNumberGenerator.new() r.seed = 7 for i in 12: var g := Game.new_battle(r) var guard := 0 while g.winner == "" and guard < 300: AI.take_turn(g, g.current(), i % 2 == 0) guard += 1 check(g.winner != "", "Spiel endet (Partie %d)" % i) ## Alte Stände behalten Münzen, Staub, Kampagne und Einstellungen; die alte Sammlung entfällt. func test_spielstand_migration() -> void: var alt := {"version": 5, "coins": 300, "dust": 70, "campaign_stage": 2, "collection": {"f_kobold": 2}, "decks": [], "settings": {"volume": 0.3}, "perk": "resonanz_doppelt"} var p := Profile.from_dict(alt) check(p.coins == 300 and p.dust == 70 and p.campaign_stage == 2, "alter Stand gelesen") check(p.collection.is_empty() and p.perk == "", "Sammlung auf Standard, unbekanntes Merkmal verworfen") check(is_equal_approx(p.settings.volume, 0.3) and p.settings.animations, "Einstellungen übernommen") p.collection["f_wolf"] = 3 var d := p.to_dict() check(d.version == Profile.SAVE_VERSION, "Version gespeichert") var p2 := Profile.from_dict(JSON.parse_string(JSON.stringify(d))) check(p2.level("f_wolf") == 3 and p2.campaign_stage == 2, "neuer Stand gelesen") func test_kampagne() -> void: var r := RandomNumberGenerator.new() r.seed = 4 check(Campaign.STAGES.size() == 10, "zehn Stufen") var boss := Campaign.opponent(4) check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 24, "Stufe 5: Vulkar mit 24 Leben") var g := Game.new_battle_with(boss, r) check(g.opponent == boss and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet") var p := Profile.starter() var reward := p.record_battle(true, 0) check(p.campaign_stage == 1 and reward == Campaign.STAGES[0].reward, "Stufe 1 abgeschlossen") reward = p.record_battle(true, 0) check(p.campaign_stage == 1 and reward == Profile.REWARD_WIN, "Wiederholung gibt normale Belohnung") reward = p.record_battle(false, 1) check(p.campaign_stage == 1 and reward == Profile.REWARD_LOSS, "Niederlage schaltet nichts frei") p.campaign_stage = 9 p.record_battle(true, 9) check(p.campaign_stage == 10, "Kampagne abgeschlossen") var sim := Game.new_battle_with(Campaign.opponent(0), r) var guard := 0 while sim.winner == "" and guard < 300: AI.take_turn(sim, sim.current(), true) guard += 1 check(sim.winner != "", "Kampagnenduell endet") func test_zufallssamen() -> void: Game.test_seed = 77 var a := Game.make_rng().randi() Game.test_seed = 77 var b := Game.make_rng().randi() Game.test_seed = 0 check(a == b, "fester Samen wiederholbar") var c := Game.make_rng().randi() var d := Game.make_rng().randi() check(c != d, "ohne Samen echter Zufall") func test_spielstand_sicherung() -> void: var path := "user://test_sicherung.json" var abs := ProjectSettings.globalize_path(path) for suffix in ["", ".bak", ".corrupt"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) var p := Profile.load_or_new(path) p.coins = 123 p.save(path) p.coins = 456 p.save(path) check(FileAccess.file_exists(path + ".bak"), "Sicherung angelegt") check(Profile.load_or_new(path).coins == 456 and Profile.load_or_new(path + ".bak").coins == 123, "Sicherung ist der Vorgänger") var f := FileAccess.open(path, FileAccess.WRITE) f.store_string("{kaputt") f.close() check(Profile.load_or_new(path).coins == 123, "kaputter Stand: Sicherung greift") for suffix in ["", ".bak", ".corrupt"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) func test_merkmale() -> void: var s := setup(["f_wolf", "l_falke"]) var g: Game = s[0] var h: Game.Player = s[1] h.perks = ["feuer_angriff", "wesen_angriff", "wesen_leben", "schild_wesen", "ziehen_gratis"] g.play_card(h, h.hand[0]) var wolf: Game.Creature = h.board[0] check(wolf.power() == 3 + 1 + 1 and wolf.health == 3 and wolf.shield and wolf.can_attack(), "Feuer +1, Wesen +1/+1, Schild") g.play_card(h, h.hand[0]) check(h.board[1].power() == 3 + 1, "Luft: nur Wesen +1") g.deck = DeckGen.deck(g.rng) var mana := h.energy check(g.draw(h) == "" and h.energy == mana, "Merkmal: Ziehen kostet nichts") g.pick(h, 0) for perk in Game.PERKS: check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk) var r := RandomNumberGenerator.new() r.seed = 2 var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1), r).opponent check(mara.life == 22 + 5 and mara.max_life == 27 and mara.perks == ["leben_35"], "Mara hat mit ihrem Merkmal 5 Leben mehr") var g3 := Game.new_battle_with(Campaign.opponent(9), r, true) var archi: Game.Player = g3.opponent check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt") g3.end_turn() check(g3.current() == archi and archi.energy == Game.START_MANA + 2 + 1, "Archimagier hat im ersten Zug 2 Mana mehr (plus Ausgleich)") var nyx: Game.Player = Campaign.opponent(5) var g4 := Game.new_battle_with(nyx, r, true) g4.end_turn() check(nyx.max_energy == Game.START_MANA and nyx.energy == Game.START_MANA + 1 + 1, "Nyx bekommt 1 Mana mehr je Zug") func test_ki_zwei_schritte() -> void: # Gegner hat 5 Leben und kein Wesen; der Wüstenkönig (5 Angriff) liegt auf der Hand: # erst ausspielen, dann mit ihm töten. var s := setup(["el_koenig"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] h.turns = 2 o.life = 5 put(h, "f_wolf", false) var act := AI.choose_action(g, h) check(act.type == "play" and act.card == h.hand[0] and not act.face_down, "KI spielt zuerst den König, offen") AI.apply_action(g, h, act) var act2 := AI.choose_action(g, h) check(act2.type == "attack" and act2.target == o, "dann tödlicher Angriff") AI.apply_action(g, h, act2) check(g.winner == "human", "Zwei-Schritt-Plan gewinnt") func test_snapshot() -> void: var s := setup(["f_wolf"], ["w_frost"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] g.upgrade(h, h.hand[0], "Schild") g.play_card(h, h.hand[0]) put(o, "e_troll") var hi := g.players.find(h) var oi := g.players.find(o) h.energy = 5 o.energy = 5 g.deck = DeckGen.deck(g.rng) g.draw(h) o.offer = g.draw_cards(3, 5) o.picks_left = 1 var snap := g.snapshot(hi) var json: Dictionary = JSON.parse_string(JSON.stringify(snap)) var g2 := Game.from_snapshot(json) check(g2.players.size() == 2 and g2.turn == g.turn and g2.current_idx == g.current_idx and g2.deck.size() == g.deck.size(), "Grunddaten und Stapelgröße") check(g2.players[hi].hand.is_empty(), "eigene Hand sichtbar") check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0].id != "w_frost", "fremde Hand nur als Anzahl") check(g2.players[oi].offer.is_empty() and g2.players[hi].offer.size() == Game.OFFER_SIZE and g2.players[hi].picks_left == 1, "eigene Wahl sichtbar, fremde nicht") var cr2 := g2.find_creature(h.board[0].uid) check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and cr2.shield and "Schild" in cr2.card.keywords, "Wesen mit Besitzer, Zustand und gekaufter Eigenschaft") check(g2.events.size() == g.events.size() and g2.events.back().type == g.events.back().type, "Ereignisse übertragen") check(g2.draw(g2.players[oi]) == "Nicht dein Zug", "Kopie kennt die Regeln") var snap_o := g.snapshot(oi) check(snap_o.players[hi].hand is int and snap_o.players[oi].hand is Array, "Sicht des Gegners") func test_kapitel_zwei_und_merkmalwahl() -> void: check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln") check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx", "Kapitel 3 mit drei Merkmalen") for i in Campaign.total(): var st := Campaign.stage(i) check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1)) var r := RandomNumberGenerator.new() r.seed = 8 var archi2 := Campaign.opponent(19) check(archi2.name == "Archimagier" and archi2.perks.size() == 2 and archi2.life == 28, "letzte Stufe von Kapitel 2: Archimagier mit zwei Merkmalen und 28 Leben") var p := Profile.starter() check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1") p.campaign_stage = 10 check(p.perk_unlocked(), "nach Kapitel 1 freigeschaltet") p.perk = "feuer_angriff" var g := Game.new_battle(r, {"human_perks": [p.perk]}) check(g.human.perks == ["feuer_angriff"] and g.log.any(func(l): return l.begins_with("Spieler: Feuerwesen")), "eigenes Merkmal wirkt und steht im Verlauf") p.campaign_stage = 19 p.record_battle(true, 19) check(p.campaign_stage == 20, "Kapitel 2 abgeschlossen") var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "Version 5 gelesen") func test_ki_zeitbudget() -> void: var r := RandomNumberGenerator.new() r.seed = 12 var g := Game.new_battle(r) var h: Game.Player = g.players[0] for p in g.players: p.offer = [] p.picks_left = 0 h.energy = 30 for i in Game.MAX_HAND: h.hand.append(C("f_ritter")) for p in g.players: for i in 3: put(p, "e_troll") AI.time_budget_ms = 1 var t0 := Time.get_ticks_msec() var act := AI.choose_action(g, h) var ms := Time.get_ticks_msec() - t0 AI.time_budget_ms = 150 check(act.type in ["play", "attack", "upgrade", "fuse", "draw", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms) var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict()))) check(v.settings.has("big_font") and v.coins == Profile.START_COINS, "Starterstand gelesen") ## Englisch: jede Karte hat einen englischen Namen, gekaufte Eigenschaften werden übersetzt. func test_englische_karten() -> void: check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen") var ritter := C("f_ritter") ritter.keywords = ["Schild", "Doppelschlag"] TranslationServer.set_locale("en") check(ritter.title() == "Blazing Knight" and ritter.text() == "Shield, Double Strike.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) TranslationServer.set_locale("de") check(ritter.title() == "Lodernder Ritter" and ritter.text() == "Schild, Doppelschlag.", "zurück auf Deutsch") ## Voller Spielstand (viewer -1) stellt Hände und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes. func test_kampf_speichern() -> void: var r := RandomNumberGenerator.new() r.seed = 7 var g := Game.new_battle(r) AI.take_turn(g, g.current()) var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1))) var g2 := Game.from_snapshot(d) var same := g2.players.size() == g.players.size() for i in g.players.size(): var a: Game.Player = g.players[i] var b: Game.Player = g2.players[i] var ids := func(cs: Array): return cs.map(func(c): return c.id) same = same and ids.call(a.hand) == ids.call(b.hand) \ and a.life == b.life and a.board.size() == b.board.size() and a.is_human == b.is_human check(same and g2.current_idx == g.current_idx and g2.turn == g.turn and g2.human.is_human, "voller Spielstand: Hände und Bretter erhalten") g2.deck = DeckGen.deck(r) # der Stapel reist nur als Größe, zum Weiterspielen braucht er Karten var guard := 0 while g2.winner == "" and guard < 300: AI.take_turn(g2, g2.current()) guard += 1 check(g2.winner != "", "der volle Spielstand ist bis zum Ende spielbar") var s2: Dictionary = g.snapshot(-2) check(s2.players.all(func(p): return not (p.hand is Array) and p.offer.is_empty()), "Zuschauer-Sicht ohne Hände und Wahlen") ## Jeder tr("...")-Text in der Oberfläche hat einen englischen Eintrag. func test_uebersetzung_vollstaendig() -> void: var keys := {} var csv := FileAccess.get_file_as_string("res://translations/ui.csv") for line in csv.split("\n"): if line.is_empty() or line.begins_with("keys,"): continue var key := line if line.begins_with("\""): key = line.substr(1, line.find("\",", 1) - 1) else: key = line.substr(0, line.find(",")) keys[key] = true var missing := [] for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + Cards.KEYWORD_TEXT.values() + Game.PERKS.values(): if not keys.has(k): missing.append(k) var d := DirAccess.open("res://scripts/ui") d.list_dir_begin() var name := d.get_next() while name != "": if name.ends_with(".gd"): var src := FileAccess.get_file_as_string("res://scripts/ui/" + name) var rx := RegEx.new() rx.compile("tr\\(\"([^\"]+)\"\\)") for m in rx.search_all(src): var k: String = m.get_string(1) if not keys.has(k) and not (k in missing): missing.append(k) name = d.get_next() var rx_cards := RegEx.new() rx_cards.compile("tr\\(\"([^\"]+)\"\\)") for m in rx_cards.search_all(FileAccess.get_file_as_string("res://scripts/cards.gd")): if not keys.has(m.get_string(1)) and not (m.get_string(1) in missing): missing.append(m.get_string(1)) check(missing.is_empty(), "fehlende Übersetzungen: %s" % [missing])