Kampf fortsetzen, Gamepad im Deckbau, Deck-Codes, Kapitel 3, Elementbooster, englische Karten, Host-Wechsel und Zuschauer

- KI-Bewertung mit Bedrohung und Führenden-Bremse (62:38 gegen die alte im Duell, gleichauf zu viert)
- Balance: Sand +1/+1 statt Schild, Erde+Luft und Erde geschwächt, Wasser angehoben, Messlauf mit N=200 möglich
- Einzelspieler-Kämpfe werden laufend gespeichert (voller Spielstand, viewer -1) und im Menü fortgesetzt
- Karten, Deckzeilen und Kartenrücken fokussierbar: Gamepad in Deckbau, Werkstatt und Booster
- Kartennamen (name_en) und Kartentexte übersetzbar, Übersetzungstest deckt cards.gd mit ab
- Deck-Code DINGER1:id*anzahl mit Zwischenablage; acht neue Karten; Kampagne Kapitel 3; Elementbooster 120 Münzen
- Netzwerk: Nachfolge-Host mit vollem Spielstand, Neuverbindung der anderen, Zuschauer (Sicht -2); Bot kann hosten
- Große Schrift: Kartennamen, Kartentext, Knopfleisten, Gegnervorschau, Dialogtext
- Anleitung 1 garantiert ein 1-Energie-Wesen; Tests: DINGER_ONLY für einzelne Oberflächentests
- Oberflächentest auch auf Xephyr mit llvmpipe (wie Xvfb in der CI) grün

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 18:50:09 +02:00
parent b6d5916a8d
commit 4c6bf96eeb
32 changed files with 1355 additions and 174 deletions

View File

@@ -55,7 +55,11 @@ func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature:
func _initialize() -> void:
TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg
test_kartenpool()
test_kampf_speichern()
test_elementbooster_und_deckcode()
test_englische_karten()
test_kombos()
test_wesen_spielen()
test_flink()
@@ -117,7 +121,7 @@ func _initialize() -> void:
func test_kartenpool() -> void:
check(Cards.all().size() == 110, "110 Karten")
check(Cards.all().size() == 118, "118 Karten")
for key in Cards.COMBOS:
var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key)
check(found, "Kombo vorhanden: " + key)
@@ -462,7 +466,7 @@ func test_spielstand() -> void:
p.coins = 999
p.save(path)
var p2 := Profile.load_or_new(path)
check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 42, "geladen")
check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == Cards.of_rarity("gewöhnlich").size() + 2, "geladen")
for suffix in ["", ".bak"]:
if FileAccess.file_exists(path + suffix):
DirAccess.remove_absolute(abs + suffix)
@@ -551,7 +555,7 @@ func test_feldkombos() -> void:
s[2][1].life = 5
g.play_card(h, C("l_wind"))
check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner")
# Schlamm heilt, Sturm zieht, Sand gibt Schild
# Schlamm heilt, Sturm zieht, Sand gibt +1/+1
s = setup(["e_wall", "l_wind", "w_reiter"])
g = s[0]
h = s[1]
@@ -562,13 +566,13 @@ func test_feldkombos() -> void:
var hand_before := h.hand.size()
g.play_card(h, C("l_wind"))
check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)")
check(not h.board[2].shield, "nur eine Feldkombo je Ausspielen")
check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen")
s = setup(["l_wind"])
g = s[0]
h = s[1]
put(h, "e_wall")
g.play_card(h, C("l_wind"))
check(h.board[1].shield, "Sand: neues Wesen hat Schild")
check(h.board[1].attack == 2 and h.board[1].health == 2, "Sand: neues Wesen erhält +1/+1")
# Gleiche Elemente lösen keine Feldkombo aus
s = setup(["f_kobold"])
g = s[0]
@@ -736,7 +740,7 @@ func test_fallen() -> void:
g.end_turn()
var a := put(o, "e_troll")
check(g.attack(a, h) == "", "Angriff in die Feuerfalle")
check(h.traps.is_empty() and a.health == 3 and h.life == 26, "Feuerfalle: 3 Schaden, Angriff geht durch")
check(h.traps.is_empty() and a.health == 3 and h.life == 27, "Feuerfalle: 3 Schaden, Angriff geht durch")
# Frostfalle bricht den Angriff ab
s = setup(["w_falle"])
g = s[0]
@@ -1089,7 +1093,7 @@ func test_deck_umbenennen_und_auffuellen() -> void:
func test_neue_fallen() -> void:
check(Cards.all().filter(func(c): return c.is_trap()).size() == 10, "zehn Fallen")
check(Cards.all().filter(func(c): return c.is_trap()).size() == 12, "zwölf Fallen")
check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser")
check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text")
# Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft
@@ -1144,7 +1148,11 @@ func test_neue_fallen() -> void:
func test_kapitel_zwei_und_merkmalwahl() -> void:
check(Campaign.total() == 20 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2, "zwanzig Stufen in zwei Kapiteln")
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" and Campaign.target_rating(29, 1.0) == 4.4, "Kapitel 3 mit drei Merkmalen und höherer Zielstärke")
for st in Campaign.STAGES3:
for pk in st.perks:
check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk)
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))
@@ -1362,6 +1370,73 @@ func test_ki_zeitbudget() -> void:
check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern")
## Elementbooster enthalten nur Karten des Elements; Deck-Codes sind umkehrbar und robust.
## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt.
func test_englische_karten() -> void:
check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen")
var de: String = Cards.by_id("f_funke").text()
TranslationServer.set_locale("en")
var funke := Cards.by_id("f_funke")
var falle := Cards.by_id("w_falle")
var wall := Cards.by_id("e_wall")
check(funke.title() == "Spark" and funke.text() == "2 damage to a target.", "Funke auf Englisch: %s / %s" % [funke.title(), funke.text()])
check(falle.text().begins_with("Trap. When an enemy creature attacks") and "The attack fizzles" in falle.text(), "Fallentext auf Englisch: %s" % falle.text())
check(wall.text() == "Guardian." and Cards.by_id("l_spiegel").text().ends_with("Summon a Wind Spirit."), "Schlüsselwort und Beschwörung auf Englisch")
TranslationServer.set_locale("de")
check(funke.title() == "Funke" and funke.text() == de, "zurück auf Deutsch")
func test_elementbooster_und_deckcode() -> void:
var p := Profile.starter()
p.coins = 500
var r := RandomNumberGenerator.new()
r.seed = 5
var got: Array = p.open_booster(r, "Wasser")
check(got.size() == 5 and got.all(func(c): return "Wasser" in c.elements) and p.coins == 500 - Profile.ELEMENT_BOOSTER_COST, "Wasser-Booster: fünf Wasserkarten, %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
p.coins = 100
check(p.open_booster(r, "Feuer").is_empty() and p.coins == 100, "Elementbooster braucht %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
for e in Cards.ELEMENTS:
for rar in Profile.RARITY_WEIGHTS:
check(not Profile.booster_pool(rar, e).is_empty(), "%s hat %s Karten" % [e, rar])
var deck: Array = Profile.starter().deck
var code := Profile.deck_code(deck)
check(code.begins_with(Profile.CODE_PREFIX) and Profile.parse_deck_code(code).size() == 20, "Deck-Code hin und zurück")
var back: Array = Profile.parse_deck_code(code)
back.sort()
var orig: Array = deck.duplicate()
orig.sort()
check(back == orig, "Deck-Code enthält genau die Karten")
check(Profile.parse_deck_code("kaputt").is_empty() and Profile.parse_deck_code("DINGER1:x_nix*2").is_empty()
and Profile.parse_deck_code("DINGER1:f_funke*9").size() == Game.MAX_COPIES, "ungültige Codes leer, Kopien gedeckelt")
check(Cards.by_id("w_flutfalle").is_trap() and Cards.by_id("fl_sturmfalke").combo() == "Blitz" and Cards.by_id("el_sandsturm").is_combo(), "neue Karten im Pool")
## Voller Spielstand (viewer -1) stellt Hände, Decks 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(Profile.starter().deck_cards(), 2, r)
g.start()
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 ids.call(a.deck) == ids.call(b.deck) \
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, Decks und Bretter erhalten")
var guard := 0
while g2.winner == "" and guard < 300:
AI.take_turn(g2, g2.current())
guard += 1
check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar")
var s2: Dictionary = g.snapshot(-2)
check(s2.players.all(func(p): return not (p.hand is Array) and not (p.traps is Array) and not (p.deck is Array)), "Zuschauer-Sicht ohne Hände, Decks und Fallen")
## Jeder tr("...")-Text in der Oberfläche und jeder Anleitungsschritt hat einen englischen Eintrag.
func test_uebersetzung_vollstaendig() -> void:
var keys := {}
@@ -1376,6 +1451,9 @@ func test_uebersetzung_vollstaendig() -> void:
key = line.substr(0, line.find(","))
keys[key] = true
var missing := []
for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + ["Wesen", "Zauber", "Falle"] + Cards.TRIGGER_TEXT.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()
@@ -1389,6 +1467,11 @@ func test_uebersetzung_vollstaendig() -> void:
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))
for stepset in [BattleTutorial.STEPS1, BattleTutorial.STEPS2]:
for st in stepset:
if not keys.has(st[0]):