Dinger: Kartenspiel in Godot 4.7

Elemente, Kombos, Fallen, KI mit Vorausschau, Kampagne, Hotseat und Netzwerk,
Werkstatt, Erfolge, Tagesaufgaben, Tests und Exporte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
Team3
2026-09-16 13:22:07 +02:00
commit 536a523f50
84 changed files with 9859 additions and 0 deletions

11
tests/dump_cards.gd Normal file
View File

@@ -0,0 +1,11 @@
extends SceneTree
## Schreibt den Kartenpool als JSON: godot --headless --path . -s tests/dump_cards.gd
func _initialize() -> void:
var list := Cards.all().map(func(c): return c.to_dict())
var f := FileAccess.open("res://data/karten.json", FileAccess.WRITE)
f.store_string(JSON.stringify({"karten": list}, "\t", false))
f.close()
print("%d Karten nach data/karten.json geschrieben" % list.size())
quit()

1
tests/dump_cards.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bdywlyx7noq6

35
tests/matchup.gd Normal file
View File

@@ -0,0 +1,35 @@
extends SceneTree
## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar.
func deck(r: RandomNumberGenerator, mono: bool) -> Array:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]])
func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool:
var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x")
var g := Game.new_battle_with(deck(r, first_mono), [second], r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current(), true)
guard += 1
return g.winner == "human"
func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void:
var wins := 0
for i in games:
if duel(r, a_mono, b_mono):
wins += 1
print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games])
func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 5
run("Paar gegen Paar", false, false, 200, r)
run("Mono gegen Mono", true, true, 200, r)
run("Paar (startet) gegen Mono", false, true, 200, r)
run("Mono (startet) gegen Paar", true, false, 200, r)
quit()

1
tests/matchup.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bu5hjcfuinhy8

65
tests/net_bot.gd Normal file
View File

@@ -0,0 +1,65 @@
extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge.
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
var net: Net
var seat := -1
var game: Game
var t := 0.0
func _ready() -> void:
var ip := "127.0.0.1"
var bot_name := "Bot"
for a in OS.get_cmdline_user_args():
if a.begins_with("--ip="):
ip = a.substr(5)
if a.begins_with("--name="):
bot_name = a.substr(7)
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name
net.my_deck = Profile.starter().deck.duplicate()
net.started.connect(func(s): seat = s)
net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame
net.join(ip)
func _process(delta: float) -> void:
t += delta
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
net.leave()
get_tree().quit()
func _on_snapshot(snap: Dictionary) -> void:
game = Game.from_snapshot(snap)
if game.winner != "":
await get_tree().create_timer(1.0).timeout
get_tree().quit()
return
if game.current_idx != seat:
return
var me: Game.Player = game.players[seat]
if not me.mulligan_done and me.turns == 1:
net.send_action({"type": "mulligan", "hand": []})
return
for i in me.hand.size():
var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0] and card.target_type() == "":
net.send_action({"type": "play", "hand": i, "target": null})
return
for cr in me.board:
if cr.can_attack():
for e in game.enemies_of(me):
if game.can_attack(cr, e)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}})
return
for c in e.board:
if game.can_attack(cr, c)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
return
net.send_action({"type": "end"})

1
tests/net_bot.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dcbqt8ybdkhrb

6
tests/net_bot.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"]
[node name="NetBot" type="Node"]
script = ExtResource("1")

8
tests/run_headless.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
set -euo pipefail
GODOT="${1:-godot}"
cd "$(dirname "$0")/.."
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log

43
tests/simulate.gd Normal file
View File

@@ -0,0 +1,43 @@
extends SceneTree
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
## ist etwa 1/(Gegner+1) Siege zu erwarten:
## godot --headless --path . -s tests/simulate.gd
func _initialize() -> void:
var games := 100
var r := RandomNumberGenerator.new()
r.seed = 0
var starter := Profile.starter().deck_cards()
var random_deck := func(rr: RandomNumberGenerator) -> Array:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, rr)
return Game.build_opponent_deck(rr, [pool[0], pool[1]])
var t0 := Time.get_ticks_msec()
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
for n in range(1, Game.MAX_OPPONENTS + 1):
_run("Zufallsdeck", random_deck, n, games, r, true, true)
print("Du mit Vorausschau, Gegner gierig:")
for n in [1, 2, 3]:
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true)
print("Alle mit Vorausschau:")
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false)
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false)
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
quit()
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator,
human_greedy: bool, opp_greedy: bool) -> void:
var wins := 0
var turns := 0
for i in games:
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
while g.winner == "":
var p := g.current()
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
assert(g.turn < 300, "Spiel endet nicht")
if g.winner == "human":
wins += 1
turns += g.turn
print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games])

1
tests/simulate.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bnbiq4krauuyq

1198
tests/test_runner.gd Normal file

File diff suppressed because it is too large Load Diff

1
tests/test_runner.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://cgjm40u037dnl

674
tests/ui_test.gd Normal file
View File

@@ -0,0 +1,674 @@
extends Node
## Oberflächentest mit echtem Fenster und Screenshots:
## DINGER_SHOTS=/pfad/zum/ordner godot --path . tests/ui_test.tscn
const SAVE := "user://test_ui_spielstand.json"
var main: Control
var shots := ""
var passed := 0
var failed := 0
func check(cond: bool, msg: String) -> void:
if cond:
passed += 1
else:
failed += 1
printerr("FEHLER: " + msg)
func frames(n: int = 2) -> void:
for i in n:
await get_tree().process_frame
func shot(name: String) -> void:
if shots == "":
return
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(shots.path_join(name + ".png"))
func click_at(pos: Vector2) -> void:
var down := InputEventMouseButton.new()
down.position = pos
down.global_position = pos
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
get_viewport().push_input(down)
var up: InputEventMouseButton = down.duplicate()
up.pressed = false
get_viewport().push_input(up)
await frames(2)
func click(node: Control) -> void:
check(node != null, "Klickziel vorhanden")
if node != null:
await click_at(node.get_global_rect().get_center())
func right_click() -> void:
var down := InputEventMouseButton.new()
down.position = Vector2(5, 5)
down.global_position = down.position
down.button_index = MOUSE_BUTTON_RIGHT
down.pressed = true
get_viewport().push_input(down)
var up: InputEventMouseButton = down.duplicate()
up.pressed = false
get_viewport().push_input(up)
await frames(2)
func key(code: int) -> void:
var down := InputEventKey.new()
down.keycode = code
down.pressed = true
get_viewport().push_input(down)
var up := InputEventKey.new()
up.keycode = code
up.pressed = false
get_viewport().push_input(up)
await frames(2)
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
func clear_mulligan(scr) -> void:
if scr.mulligan_box.visible:
await click(scr.b_keep)
func hover(node: Control) -> void:
var ev := InputEventMouseMotion.new()
ev.position = node.get_global_rect().get_center()
ev.global_position = ev.position
get_viewport().push_input(ev)
await frames(2)
func screen():
return main.screen
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist.
func ensure_menu() -> void:
if main.net.connected:
main.net.leave()
if TranslationServer.get_locale().begins_with("en"):
main.set_language("de")
if not (screen() is MenuScreen):
main.show_screen(MenuScreen.new())
await frames(2)
func find_button(text: String, root: Node = null) -> Button:
if root == null:
root = screen()
for c in root.get_children():
if c is Button and (c.text == text or c.tr(c.text) == text) and c.is_visible_in_tree():
return c
var found := find_button(text, c)
if found != null:
return found
return null
func card_views(root: Node) -> Array:
var out := []
for c in root.get_children():
if c is CardView:
out.append(c)
out.append_array(card_views(c))
return out
## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz).
func settle(scr) -> void:
for i in 40:
if not scr.tiles.values().any(func(t): return t.busy):
return
await frames(1)
func wait_for_human(scr) -> void:
var g: Game = scr.game
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)
wait += 1
await frames(1)
await clear_mulligan(scr)
func _ready() -> void:
Game.test_seed = 4242 # wiederholbare Partien und Booster
shots = OS.get_environment("DINGER_SHOTS")
var abs := ProjectSettings.globalize_path(SAVE)
for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + suffix)
main = load("res://scenes/main.tscn").instantiate()
main.save_path = SAVE
add_child(main)
await frames(3)
for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_hotseat,
test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]:
await ensure_menu()
await t.call()
for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + suffix)
print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed])
get_tree().quit(1 if failed > 0 else 0)
func test_menu_und_regeln() -> void:
check(screen() is MenuScreen, "Menü sichtbar")
await shot("01_menu")
await click(find_button("Regeln"))
check(screen() is RulesScreen, "Regeln geöffnet")
await shot("02_regeln")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_booster() -> void:
await click(find_button("Booster öffnen"))
var scr = screen()
check(scr is BoosterScreen, "Boosterbildschirm")
var coins: int = main.profile.coins
var ach_before: int = main.profile.achievements.size()
await click(scr.b_buy)
var bonus := 0
for a in Profile.ACHIEVEMENTS:
if a.id in main.profile.achievements.slice(ach_before):
bonus += a.reward
check(main.profile.coins == coins - Profile.BOOSTER_COST + bonus, "Booster bezahlt (plus Erfolgsmünzen %d)" % bonus)
check(scr.cards.size() == 5 and scr.revealed.is_empty(), "5 verdeckte Karten")
await shot("03_booster_zu")
var backs: Array = scr.slots.get_children().filter(func(c): return c is BoosterScreen.CardBack)
await click(backs[0])
check(scr.revealed.size() == 1, "eine Karte aufgedeckt")
await click(scr.b_all)
check(scr.revealed.size() == 5, "alle aufgedeckt")
await shot("04_booster_offen")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_deckbau() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
check(scr is DeckScreen, "Deckbildschirm")
check(scr.deck.size() == 20, "Deck geladen")
await shot("05_deck")
await click(find_button("Leeren"))
check(scr.deck.is_empty(), "geleert")
var first_id: String = card_views(scr.grid)[0].card.id
var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES)
await click(card_views(scr.grid)[0])
check(scr.deck == [first_id], "Karte hinzugefügt")
for i in allowed:
await click(card_views(scr.grid)[0])
check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed)
await click(find_button("Speichern"))
check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert")
await click(find_button("Wasser"))
await click(card_views(scr.grid)[0])
await click(find_button("Erde"))
await click(card_views(scr.grid)[0])
check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt")
await click(find_button("Alle"))
# Suche, Sortierung, fehlende Karten
var all_count := card_views(scr.grid).size()
scr.search_box.text = "Wächter"
scr.search_box.text_changed.emit("Wächter")
await frames(1)
var found := card_views(scr.grid)
check(found.size() > 0 and found.size() < all_count, "Suche filtert")
check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext")
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
scr.sort_box.select(1)
scr.sort_box.item_selected.emit(1)
await frames(1)
var names: Array = card_views(scr.grid).map(func(v): return v.card.name)
var sorted_names := names.duplicate()
sorted_names.sort()
check(names == sorted_names, "Sortierung nach Name")
scr.missing_box.button_pressed = true
await frames(1)
check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet")
var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0)
check(not missing.is_empty(), "es gibt fehlende Karten")
await click(missing[0])
check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck")
scr.missing_box.button_pressed = false
scr.sort_box.select(0)
scr.sort_box.item_selected.emit(0)
await frames(1)
var n: int = scr.deck.size()
await click(scr.deck_list.get_children()[0])
check(scr.deck.size() == n - 1, "Zeile entfernt Karte")
await hover(card_views(scr.grid)[0])
check(scr.preview.visible, "Vorschau bei Hover")
await shot("06_deck_bearbeitet")
# Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen
await click(scr.slot_buttons[1])
if scr.slot != 1 or not scr.deck.is_empty():
print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(),
" hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size())
check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer")
scr.deck = main.profile.decks[0].cards.duplicate()
scr.refresh()
await click(find_button("Speichern"))
check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert")
await click(find_button("Aktiv setzen"))
check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv")
check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert")
scr.name_box.text = "Testdeck"
scr.name_box.text_submitted.emit("Testdeck")
check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt")
await click(scr.slot_buttons[2])
check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer")
await click(find_button("Auto-Ausfüllen"))
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
await shot("25_deck_slots")
await click(find_button("Zurück"))
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
func target_view(scr, valid: Array) -> Control:
for v in scr.enemy_views:
if v.creature in valid:
return v
for v in scr.own_views:
if v.creature in valid:
return v
for p in scr.hero_panels:
if p in valid:
return scr.hero_panels[p]
return null
func human_turn(scr) -> void:
var g: Game = scr.game
await clear_mulligan(scr)
for i in 12:
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
if playable.is_empty():
break
var card: Cards.Card = playable[0].card
var energy: int = g.human.energy
await click(playable[0])
if scr.pending != null:
await click(target_view(scr, g.valid_targets(g.human, card.target_type())))
check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name)
for i in 12:
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
if ready.is_empty() or g.winner != "":
break
var cr: Game.Creature = ready[0].creature
await settle(scr)
await hover(ready[0])
var under = get_viewport().gui_get_hovered_control()
await click(ready[0])
if scr.selected != cr:
print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale,
" busy=", ready[0].busy, " unter Maus=", under, " Meldung='", scr.msg_label.text,
"' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected)
check(scr.selected == cr, "Wesen gewählt")
var targets := []
for o in g.opponents:
if o.alive() and g.can_attack(cr, o)[0]:
targets.append(o)
for c in o.board:
if g.can_attack(cr, c)[0]:
targets.append(c)
await settle(scr)
await click(target_view(scr, targets))
check(scr.selected == null and cr.attacks_left < cr.attacks_per_turn(), "Angriff ausgeführt")
if g.winner == "":
await click(scr.b_end)
func start_battle(n_opp: int) -> void:
await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl")
await click(find_button("%d Gegner" % n_opp))
func test_gegnerauswahl() -> void:
await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet")
await shot("07_gegnerauswahl")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
for n in [1, 5]:
await start_battle(n)
var scr = screen()
check(scr is BattleScreen and scr.game.opponents.size() == n, "Kampf gegen %d gestartet" % n)
check(scr.game.players[0].max_energy == 1 and scr.game.opponents[0].life == Game.START_LIFE, "gleiche Regeln gegen %d" % n)
await shot("11_kampf_%d" % n)
await give_up()
func give_up() -> void:
var scr = screen()
await clear_mulligan(scr)
await click(find_button("Aufgeben"))
check(scr.confirm.visible, "Aufgeben fragt nach")
await click(find_button("Weiterspielen"))
check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf")
await click(find_button("Aufgeben"))
await click(find_button("Ja, aufgeben"))
check(screen() is MenuScreen, "Aufgeben führt ins Menü")
func test_einstellungen() -> void:
await click(find_button("Einstellungen"))
var scr = screen()
check(scr is SettingsScreen, "Einstellungen geöffnet")
scr.volume.value = 0.3
scr.speed.value = 2.0
scr.anim.button_pressed = false
await frames(1)
check(is_equal_approx(main.profile.settings.volume, 0.3) and is_equal_approx(main.profile.settings.ai_speed, 2.0), "Regler gespeichert")
check(main.profile.settings.animations == false, "Animationen abschaltbar")
check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte")
scr.anim.button_pressed = true
await shot("13_einstellungen")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_erfolge() -> void:
await click(find_button("Erfolge"))
check(screen() is AchievementsScreen, "Erfolge geöffnet")
await shot("14_erfolge")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_werkstatt() -> void:
await click(find_button("Werkstatt"))
var scr = screen()
check(scr is WorkshopScreen, "Werkstatt geöffnet")
var p: Profile = main.profile
p.dust = 0
var in_decks := {}
for d in p.decks:
for cid in d.cards:
in_decks[cid] = true
var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id))
check(not free.is_empty(), "eine freie Karte zum Zerlegen")
var card: Cards.Card = free[0].card
await click(free[0])
check(scr.selected == card, "Karte gewählt")
var before: int = p.collection[card.id]
await click(scr.b_dismantle)
check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben")
p.dust = 5000
scr.search_box.text = "Phönix"
scr.search_box.text_changed.emit("Phönix")
await frames(1)
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0]
await click(phoenix)
await click(scr.b_craft)
check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt")
check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Werkstatt gespeichert")
await shot("20_werkstatt")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_hotseat() -> void:
await click(find_button("Lokal zu mehreren"))
var scr = screen()
check(scr is HotseatScreen, "Hotseat-Auswahl")
await click(find_button("3 Spieler"))
await click(find_button("1 KI"))
check(scr.n_humans == 3 and scr.n_ai == 1, "3 Menschen, 1 KI")
await shot("17_hotseat")
await click(find_button("Start"))
var b = screen()
check(b is BattleScreen and b.is_hotseat() and b.game.players.size() == 4, "Hotseat-Kampf gestartet")
b.ai_delay = 0.0
var guard := 0
while not b.handover.visible and b.game.winner == "" and guard < 400:
if b.game.current() == b.perspective:
await clear_mulligan(b)
if b.game.current() == b.perspective:
await click(b.b_end)
await frames(1)
guard += 1
check(b.handover.visible, "Übergabe erscheint")
var cur: Game.Player = b.game.current()
check(cur.is_human and cur != b.perspective, "anderer Mensch ist dran")
await shot("18_hotseat_uebergabe")
await click(find_button("Weiter"))
check(b.perspective == cur and not b.handover.visible, "Perspektive gewechselt")
check(b.hand_views.size() == cur.hand.size() and b.name_label.text == cur.name, "Hand und Name des neuen Spielers")
check(b.mulligan_box.visible, "neuer Spieler bekommt seine Mulligan-Maske")
await clear_mulligan(b)
check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch")
await shot("19_hotseat_kampf")
await give_up()
func test_sprache() -> void:
await click(find_button("Einstellungen"))
var scr = screen()
scr.language.select(1)
scr.language.item_selected.emit(1)
await frames(2)
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
check(find_button("Back") != null, "Knöpfe übersetzt")
await shot("26_englisch")
await click(find_button("Back"))
check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch")
await click(find_button("Settings"))
var scr2 = screen()
scr2.language.select(0)
scr2.language.item_selected.emit(0)
await frames(2)
check(find_button("Zurück") != null, "zurück auf Deutsch")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_anleitung() -> void:
check(not main.profile.tutorial_done, "Anleitung noch offen")
await click(find_button("Anleitung"))
var scr = screen()
check(scr is BattleScreen and scr.tutorial, "Anleitung gestartet")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.players[0] == g.human and g.opponents[0].name == "Fenn" and g.opponents[0].perks.is_empty(), "Fenn ohne Merkmal, Mensch beginnt")
await frames(2)
check(scr.mulligan_box.visible, "Mulligan-Maske im ersten Zug")
var views = scr.mulligan_layer.get_children()
await click(views[0])
check(scr.mulligan_selected == [0], "Karte zum Tausch gewählt")
await shot("22_anleitung_mulligan")
await click(scr.b_swap)
check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht")
check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf")
check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar")
var idx := -1
for i in g.human.hand.size():
if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1:
idx = i
break
if idx >= 0:
await key(KEY_1 + idx)
check(g.human.board.size() == 1, "Wesen per Taste gespielt")
check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen")
await shot("22_anleitung")
await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
await wait_for_human(scr)
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
var step_before: int = scr.tutorial_step
await click(scr.b_tut_next)
check(scr.tutorial_step == step_before + 1, "Weiter-Knopf")
await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben")
await key(KEY_ESCAPE)
check(not scr.confirm.visible, "Esc schließt den Dialog")
await give_up()
check(main.profile.tutorial_done, "Anleitung als erledigt gespeichert")
func test_netzwerk() -> void:
await click(find_button("Netzwerkspiel"))
var scr = screen()
check(scr is LobbyScreen, "Lobby geöffnet")
await click(find_button("Spiel eröffnen"))
check(main.net.is_host and main.net.connected, "Spiel eröffnet")
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"]
var pid := OS.create_process(OS.get_executable_path(), args)
check(pid > 0, "Bot-Prozess gestartet")
var wait := 0
while main.net.names.size() < 2 and wait < 900:
await frames(1)
wait += 1
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
await shot("23_lobby")
await click(find_button("Start"))
var b = screen()
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
b.ai_delay = 0.0
var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 4:
if b.game.current() == b.perspective:
await clear_mulligan(b)
if b.game.current() == b.perspective:
await click(b.b_end)
var w2 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w2 < 900:
await frames(1)
w2 += 1
rounds += 1
check(b.game.turn >= 3 and b.game.winner == "", "mehrere Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt")
await shot("24_netzwerk_kampf")
OS.kill(pid)
var w3 := 0
while b.game.winner == "" and w3 < 1500:
await frames(1)
w3 += 1
if b.game.winner != "":
check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe")
await frames(2)
check(b.overlay.visible, "Spielende angezeigt")
await click(find_button("Zurück zum Menü"))
else:
print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen")
await give_up()
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
func test_kampagne() -> void:
await click(find_button("Kampagne"))
var scr = screen()
check(scr is CampaignScreen, "Kampagne geöffnet")
await shot("15_kampagne")
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
await click(find_button("Kämpfen"))
var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner")
await shot("16_kampagne_kampf")
await give_up()
func test_kampf(n_opp: int, shot_turn: int) -> void:
await start_battle(n_opp)
var scr = screen()
check(scr is BattleScreen, "Kampfbildschirm")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
await wait_for_human(scr)
check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt")
check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar")
await shot("07_kampf_start")
var coins: int = main.profile.coins
var guard := 0
while g.winner == "" and guard < 60:
guard += 1
var turn_before := g.turn
await human_turn(scr)
if g.winner != "":
break
check(g.current() != g.human or g.turn > turn_before, "Zug beendet")
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)
wait += 1
if guard == shot_turn:
await shot("08_kampf_zug%d" % (shot_turn + 1))
check(g.winner != "", "Spiel beendet")
await frames(3)
await shot("09_kampf_ende")
check(scr.reward > 0 and main.profile.coins >= coins + scr.reward, "Belohnung gutgeschrieben")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Belohnung gespeichert")
await click(find_button("Zurück zum Menü"))
check(screen() is MenuScreen, "zurück im Menü")
func test_kampf_abbrechen() -> void:
await start_battle(5)
var scr = screen()
scr.ai_delay = 0.0
var g: Game = scr.game
await wait_for_human(scr)
check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner")
# Falle legen und Kombo-Hinweis prüfen
g.human.hand.append(Cards.by_id("f_falle"))
g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
g.human.board.append(wolf)
g.human.hand.append(Cards.by_id("w_reiter"))
scr.refresh()
var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0]
await click(trap_view)
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
await hover(reiter_view)
var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel)
check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau")
await shot("21_kampf_falle_hinweis")
var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
if not spells.is_empty():
var card: Cards.Card = spells[0].card
await click(spells[0])
check(scr.pending == card, "Ziel wird erwartet")
await right_click()
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
await hover(scr.hand_views[0])
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(find_button("Lupe"))
check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte")
await shot("12_lupe")
await click(find_button("Schließen"))
check(scr.zoom_player == null, "Lupe geschlossen")
# Runden laufen lassen, damit die Gegner ihre Züge machen
for round in 4:
await click(scr.b_end)
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)
wait += 1
var ai_attacked: bool = g.log.any(func(l): return l.begins_with("Gegner") and " spielt " in l) \
and g.log.any(func(l): return " greift " in l)
check(ai_attacked, "Gegner spielen Karten und greifen an")
check(scr.tiles.size() == g.players.reduce(func(acc, p): return acc + p.board.size(), 0), "eine Kachel je Wesen")
await shot("10_kampf_fuenf")

1
tests/ui_test.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://ci5hglrkyevqi

6
tests/ui_test.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/ui_test.gd" id="1"]
[node name="UITest" type="Node"]
script = ExtResource("1")