From 7ae6017f120904492059c3f523707f1fb34a5180 Mon Sep 17 00:00:00 2001 From: Team3 Date: Wed, 16 Sep 2026 14:04:13 +0200 Subject: [PATCH] =?UTF-8?q?Deckst=C3=A4rke,=20Schwierigkeitsgrade=20mit=20?= =?UTF-8?q?Archetypen,=20Gegnerdecks=20entlang=20der=20Energiekurve,=20Sta?= =?UTF-8?q?rter=20mit=20zwei=20seltenen=20Karten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx --- README.md | 4 +- scripts/campaign.gd | 58 ++++++++------ scripts/cards.gd | 6 ++ scripts/game.gd | 114 ++++++++++++++++++++++----- scripts/profile.gd | 35 ++++++-- scripts/ui/battle_screen.gd | 22 ++++-- scripts/ui/deck_screen.gd | 3 + scripts/ui/opponent_select_screen.gd | 43 ++++++++-- scripts/ui/style.gd | 2 + scripts/ui/workshop_screen.gd | 4 +- tests/strength_probe.gd | 37 +++++++++ tests/strength_probe.gd.uid | 1 + tests/test_runner.gd | 66 +++++++++++++++- tests/ui_test.gd | 36 ++++++++- translations/ui.csv | 12 +++ translations/ui.en.translation | Bin 3848 -> 4796 bytes 16 files changed, 371 insertions(+), 72 deletions(-) create mode 100644 tests/strength_probe.gd create mode 100644 tests/strength_probe.gd.uid diff --git a/README.md b/README.md index a6f9d33..ff5456b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Der Spielstand liegt unter `user://spielstand.json` - Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1 - Wöchentliche Herausforderung mit Regelvarianten, Bestenliste (Serie, schnellster Sieg), Statistik-Export als CSV - Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei) +- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut +- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach ## Tests @@ -42,7 +44,7 @@ godot --headless --path . --import # einmalig: Ressourcen importier godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar -godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf) +godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in Game.ARCHETYPE_STRENGTH) DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots ``` diff --git a/scripts/campaign.gd b/scripts/campaign.gd index 07fa111..d5f2b97 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -2,34 +2,44 @@ class_name Campaign ## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch. const STAGES := [ - {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"}, - {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"}, - {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"}, - {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "flink_angriff"}, - {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "zauber_schaden"}, - {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"}, - {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"}, - {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"}, - {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"}, - {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "bias": 3.0, "reward": 500, "perk": "start_energie"}, + {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"}, + {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"}, + {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"}, + {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"}, + {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "zauber_schaden"}, + {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"}, + {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"}, + {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"}, + {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"}, + {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 500, "perk": "start_energie"}, ] ## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück. const STAGES2 := [ - {"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, - {"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["leben_35", "extra_karten"]}, - {"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, - {"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, - {"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]}, - {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["extra_karten", "flink_angriff"]}, - {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]}, - {"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, - {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "bias": 3.5, "reward": 500, "perks": ["alle_flink", "start_energie"]}, - {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "bias": 4.0, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]}, + {"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, + {"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_karten"]}, + {"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, + {"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, + {"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]}, + {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]}, + {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]}, + {"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, + {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 500, "perks": ["alle_flink", "start_energie"]}, + {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]}, ] +# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks. +const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6] +const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4] + + +static func target_rating(stage_index: int, player_rating: float) -> float: + var base: float = MIN_RATING[stage_index] if stage_index < STAGES.size() else MIN_RATING2[stage_index - STAGES.size()] + return maxf(base, player_rating) + + static func total() -> int: return STAGES.size() + STAGES2.size() @@ -47,13 +57,15 @@ static func stage(i: int) -> Dictionary: return STAGES2[i - STAGES.size()] -static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array: +## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist). +static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array: var st := stage(stage_index) + var target := target_rating(stage_index, player_rating) var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE - var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title) + var boss := Game.Player.new(st.name, Game.build_opponent_deck_rated(r, st.elements, target), false, life, st.title) boss.perks = Array(st.perks) var out := [boss] var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) for k in extra: - out.append(Game.random_opponent(r, k + 1)) + out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target)) return out diff --git a/scripts/cards.gd b/scripts/cards.gd index 8562122..82e4e58 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -52,6 +52,8 @@ const KEYWORD_HELP := { "Doppelschlag": "Greift zweimal pro Zug an.", } const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] +# Näherung der Kartenstärke: Seltenheit plus Zuschläge für Schlüsselwörter und Effekte. +const RARITY_POINTS := {"gewöhnlich": 1.0, "selten": 2.0, "episch": 3.0, "legendär": 4.0, "mythisch": 5.0} const DATA_PATH := "res://data/karten.json" @@ -274,6 +276,10 @@ static func has_id(id) -> bool: return _by_id.has(id) +static func card_value(c: Card) -> float: + return RARITY_POINTS[c.rarity] + 0.3 * c.keywords.size() + 0.3 * c.effects.size() + + static func of_rarity(r: String) -> Array: return all().filter(func(c): return c.rarity == r) diff --git a/scripts/game.gd b/scripts/game.gd index 6e73d9b..523b90a 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -778,43 +778,115 @@ static func validate_deck(cards: Array, owned = null) -> Array: return [true, ""] -## Gegnerdeck aus den Elementen. bias > 1 bevorzugt seltene Karten (Kampagne). +## Deckstärke: Durchschnitt der Kartenwerte (1 = nur gewöhnliche Karten, bis etwa 3,5). +static func deck_rating(cards: Array) -> float: + if cards.is_empty(): + return 0.0 + var sum := 0.0 + for c in cards: + sum += Cards.card_value(c) + return sum / cards.size() + + +## Gegnerdeck auf eine Zielstärke: die Gewichtung wird angepasst, bis das Deck nahe am Ziel liegt. +static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array: + var bias := 1.0 + var best := [] + var best_diff := 99.0 + for attempt in 24: + var deck := build_opponent_deck(r, elements, bias) + var diff := deck_rating(deck) - target + if absf(diff) < best_diff: + best = deck + best_diff = absf(diff) + if absf(diff) <= tolerance: + break + bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0) + return best + + +# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, tests/strength_probe.gd). +const ARCHETYPE_STRENGTH := { + "Feuer": 33, "Wasser": 20, "Erde": 53, "Luft": 46, + "Feuer+Wasser": 46, "Feuer+Erde": 60, "Feuer+Luft": 60, + "Wasser+Erde": 23, "Wasser+Luft": 36, "Erde+Luft": 66, +} + + +## Elementkombinationen einer Stufe: "leicht" die schwächere Hälfte, "schwer" die stärkere, sonst alle. +static func archetypes(tier: String = "") -> Array: + var keys: Array = ARCHETYPE_STRENGTH.keys() + keys.sort_custom(func(a, b): return ARCHETYPE_STRENGTH[a] < ARCHETYPE_STRENGTH[b]) + var half: int = keys.size() / 2 + match tier: + "leicht": + return keys.slice(0, half) + "schwer": + return keys.slice(half) + return keys + + +# Energiekurve der Gegnerdecks: Anzahl Karten je Kosten (6 = sechs und mehr). +const CURVE := {1: 3, 2: 5, 3: 4, 4: 3, 5: 2, 6: 3} + + +## Gegnerdeck aus den Elementen entlang der Energiekurve. bias > 1 bevorzugt seltene Karten, bias < 1 gewöhnliche. static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array: var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements)) - var weights := PackedFloat32Array() - for c in pool: - var w: float = OPPONENT_RARITY_WEIGHT[c.rarity] - weights.append(w / bias if c.rarity == "gewöhnlich" else w * bias) var deck := [] var counts := {} + for bucket in CURVE: + for i in CURVE[bucket]: + var c = _pick_for_cost(r, pool, counts, bucket, bias) + if c != null: + counts[c.id] = counts.get(c.id, 0) + 1 + deck.append(c) while deck.size() < DECK_SIZE: - var c = pool[r.rand_weighted(weights)] - if counts.get(c.id, 0) < MAX_COPIES: - counts[c.id] = counts.get(c.id, 0) + 1 - deck.append(c) + var c = _pick_for_cost(r, pool, counts, r.randi_range(1, 6), bias) + if c == null: + break + counts[c.id] = counts.get(c.id, 0) + 1 + deck.append(c) return deck -static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE) -> Player: - var elements: Array - if r.randf() < 0.4: - elements = [Cards.ELEMENTS[r.randi_range(0, Cards.ELEMENTS.size() - 1)]] - else: - var pool: Array = Cards.ELEMENTS.duplicate() - shuffle(pool, r) - elements = [pool[0], pool[1]] - return Player.new("Gegner %d" % index, build_opponent_deck(r, elements), false, life, - Cards.combo_name(elements) + "-Magier") +## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand. +## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar. +static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float): + var candidates := [] + var weights := PackedFloat32Array() + for c in pool: + if counts.get(c.id, 0) >= MAX_COPIES: + continue + var spread: int = absi(mini(c.cost, 6) - cost) + var w: float = OPPONENT_RARITY_WEIGHT[c.rarity] + w = w / bias if c.rarity == "gewöhnlich" else w * bias + w *= pow(0.3, spread) + candidates.append(c) + weights.append(w) + if candidates.is_empty(): + return null + return candidates[r.rand_weighted(weights)] -## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int} +## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit. +static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE, target: float = 0.0, tier: String = "") -> Player: + var options := archetypes(tier) + var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+") + var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements) + return Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier") + + +## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int, "target_rating": float, "tier": String} static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner") if r == null: r = make_rng() + var target: float = float(rules.get("target_rating", 0.0)) + var tier: String = str(rules.get("tier", "")) var opps := [] for i in n_opp: - opps.append(random_opponent(r, i + 1)) + opps.append(random_opponent(r, i + 1, START_LIFE, target, tier)) return new_battle_with(deck, opps, r, false, rules) diff --git a/scripts/profile.gd b/scripts/profile.gd index 4e17667..f76cd18 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -12,6 +12,12 @@ const START_COINS := 150 const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} const REWARD_LOSS := 50 const DECK_SLOTS := 5 +# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe. +const DIFFICULTIES := { + "leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"}, + "normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""}, + "schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"}, +} # Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub. const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600} const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} @@ -21,7 +27,7 @@ const ACHIEVEMENTS := [ {"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50}, {"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100}, {"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200}, - {"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100}, + {"id": "sammler", "name": "Sammler", "text": "Besitze 50 verschiedene Karten.", "reward": 100}, {"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500}, {"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100}, {"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100}, @@ -58,7 +64,7 @@ var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}} var achievements: Array = [] var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var daily := {"date": "", "task": "", "progress": 0, "done": false} -var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"} +var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal"} var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe) var card_stats := {} # id -> {"played", "games", "wins"} var booster_since_mythic := 0 @@ -86,11 +92,16 @@ static func starter() -> Profile: for c in Cards.of_rarity("gewöhnlich"): if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"): p.collection[c.id] = 2 + # Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt + p.collection["f_drache"] = 1 + p.collection["e_baum"] = 1 var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", "e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"] for cid in starter_ids: p.deck.append(cid) p.deck.append(cid) + p.deck[p.deck.find("f_asche")] = "f_drache" + p.deck[p.deck.find("e_troll")] = "e_baum" return p @@ -147,7 +158,9 @@ static func from_dict(d: Dictionary) -> Profile: var se = d.get("settings", {}) p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), - "language": str(se.get("language", "de"))} + "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal"))} + if not DIFFICULTIES.has(p.settings.difficulty): + p.settings.difficulty = "normal" if version >= 4: for cid in d.get("card_stats", {}): if Cards.has_id(cid): @@ -363,7 +376,7 @@ func open_booster(r: RandomNumberGenerator = null) -> Array: ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. ## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche. func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [], - turns: int = 0, challenge: Dictionary = {}) -> int: + turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0) -> int: stats.games += 1 if won: records.streak += 1 @@ -386,7 +399,7 @@ func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, pla stats.wins += 1 var key := str(n_opp) stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1 - reward = REWARD_WIN[n_opp] + reward = int(round(REWARD_WIN[n_opp] * reward_factor)) if stage >= 0 and stage == campaign_stage: campaign_stage += 1 reward = Campaign.stage(stage).reward @@ -440,7 +453,7 @@ func _achieved(id: String, ctx: Dictionary) -> bool: "chaosmeister": return won and n == 5 "sammler": - return distinct_owned() >= 40 + return distinct_owned() >= 50 "vollstaendig": return distinct_owned() >= Cards.all().size() "boosterfreund": @@ -460,6 +473,16 @@ func _achieved(id: String, ctx: Dictionary) -> bool: return false +func difficulty() -> Dictionary: + return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal) + + +## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor. +func target_rating(cards: Array = []) -> float: + var deck_used: Array = cards if not cards.is_empty() else deck_cards() + return Game.deck_rating(deck_used) * float(difficulty().factor) + + func perk_unlocked() -> bool: return campaign_stage >= Campaign.STAGES.size() diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 575ba2c..d61a2b2 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -28,6 +28,7 @@ var mulligan_selected: Array = [] var tutorial_step := 0 var challenge := {} # wöchentliche Herausforderung oder leer var skip_ai := false # Gegnerzüge im Schnelldurchlauf +var smart_ai := true # Vorausschau-KI (Leicht: einfache KI) var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion var undo_log_len := 0 var undo_t := 0.0 @@ -170,25 +171,29 @@ func _ready() -> void: game = Game.new_hotseat(hotseat_decks, hotseat_ai) elif tutorial: var r := Game.make_rng() - var opps := Campaign.opponents(0, r) + var opps := Campaign.opponents(0, r, Game.deck_rating(app.profile.deck_cards())) opps[0].perks = [] game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true) elif not challenge.is_empty(): - game = Game.new_battle(app.profile.deck_cards(), n_opp, null, challenge.rules) + var rules: Dictionary = challenge.rules.duplicate() + rules["target_rating"] = Game.deck_rating(app.profile.deck_cards()) + game = Game.new_battle(app.profile.deck_cards(), n_opp, null, rules) elif stage >= 0: var r := Game.make_rng() - game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r, false, own_rules()) + game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules()) else: game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules()) + smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() _begin() -## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar). +## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar) und Zielstärke der Gegner. func own_rules() -> Dictionary: var p: Profile = app.profile + var rules := {"target_rating": p.target_rating(), "tier": str(p.difficulty().tier)} if p.perk != "" and p.perk_unlocked(): - return {"human_perks": [p.perk]} - return {} + rules["human_perks"] = [p.perk] + return rules func _begin() -> void: @@ -491,7 +496,7 @@ func _process(delta: float) -> void: ai_timer -= delta if ai_timer > 0: return - var action := AI.choose_action(game, cur) + var action := AI.choose_action(game, cur) if smart_ai else AI.choose_action_greedy(game, cur) AI.apply_action(game, cur, action) ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0) if game.current().is_human: @@ -502,7 +507,8 @@ func _process(delta: float) -> void: func _finish() -> void: var won := game.winner == "human" if counts_for_profile(): - reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge) + var factor: float = float(app.profile.difficulty().reward) if stage < 0 and challenge.is_empty() and not tutorial else 1.0 + reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor) if tutorial: app.profile.tutorial_done = true app.save() diff --git a/scripts/ui/deck_screen.gd b/scripts/ui/deck_screen.gd index 1117a29..b2c010a 100644 --- a/scripts/ui/deck_screen.gd +++ b/scripts/ui/deck_screen.gd @@ -340,10 +340,13 @@ func refresh() -> void: if not name_box.has_focus(): name_box.text = p.deck_name(slot) deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE] + deck_label.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten") deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT) var elems := Game.deck_elements(dc) elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b)) elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–") + if not dc.is_empty(): + elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc) var ok: Array = Game.validate_deck(dc, p.collection) valid_label.text = "spielbar" if ok[0] else ok[1] valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE) diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index d47725b..dd100a5 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,27 +1,56 @@ class_name OpponentSelectScreen extends Control -## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird. +## Einzelspieler: Schwierigkeit und Gegnerzahl. Gegnerdecks werden auf die eigene Deckstärke gebaut. var app +var rating_label: Label +var diff_buttons := {} func _ready() -> void: - var col := Style.column(self, 900, 50, 14) + var col := Style.column(self, 900, 40, 12) Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) - Style.clabel(col, "Wie viele Gegner? Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben." % Game.START_LIFE, 18, Style.MUTED) - Style.clabel(col, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED) - Style.spacer(col, 10) + Style.clabel(col, "Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben. Du gewinnst, wenn alle anderen besiegt sind." % Game.START_LIFE, 16, Style.MUTED) + Style.spacer(col, 4) + Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true) + var dr := Style.row(col, 10) + dr.alignment = BoxContainer.ALIGNMENT_CENTER + for key in ["leicht", "normal", "schwer"]: + var k: String = key + diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 42), func(): set_difficulty(k)) + rating_label = Style.clabel(col, "", 15, Style.MUTED) + Style.spacer(col, 4) for i in range(1, Game.MAX_OPPONENTS + 1): var n := i var r := Style.row(col, 20) r.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c")) + Style.cbutton(r, "%d Gegner" % n, Vector2(200, 50), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c")) var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n] Style.clabel(r, info, 16, Style.MUTED, "left", false, 420) - Style.spacer(col, 20) + Style.spacer(col, 10) var br := Style.row(col) br.alignment = BoxContainer.ALIGNMENT_CENTER Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) + refresh() + + +func set_difficulty(key: String) -> void: + app.profile.settings.difficulty = key + app.save() + refresh() + + +func refresh() -> void: + var p: Profile = app.profile + var d := p.difficulty() + for key in diff_buttons: + var b: Button = diff_buttons[key] + var active: bool = key == p.settings.difficulty + b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2)) + b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2)) + var arche: String = tr("schwache Archetypen") if d.tier == "leicht" else (tr("starke Archetypen") if d.tier == "schwer" else tr("alle Archetypen")) + rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s") % [ + Game.deck_rating(p.deck_cards()), p.target_rating(), arche, d.reward, tr("Vorausschau") if d.smart else tr("einfach")] func _input(event: InputEvent) -> void: diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index c620fc9..a556beb 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -268,6 +268,8 @@ static func rules_text() -> String: lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.") lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.") lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.") + lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine") + lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.") lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.") lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.") return "\n".join(lines) diff --git a/scripts/ui/workshop_screen.gd b/scripts/ui/workshop_screen.gd index fccf5ff..0501cbf 100644 --- a/scripts/ui/workshop_screen.gd +++ b/scripts/ui/workshop_screen.gd @@ -138,8 +138,8 @@ func refresh() -> void: preview_holder.add_child(v) var owned: int = p.collection.get(selected.id, 0) var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0}) - info_label.text = "%s · %s\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [ - selected.combo(), selected.rarity, owned, st.played, st.games, st.wins] + info_label.text = "%s · %s · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [ + selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins] b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity] b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity] b_dismantle.disabled = owned == 0 diff --git a/tests/strength_probe.gd b/tests/strength_probe.gd new file mode 100644 index 0000000..bcb868c --- /dev/null +++ b/tests/strength_probe.gd @@ -0,0 +1,37 @@ +extends SceneTree +## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI. + + +func archetypes() -> Array: + var out := [] + for e in Cards.ELEMENTS: + out.append([e]) + for i in Cards.ELEMENTS.size(): + for j in range(i + 1, Cards.ELEMENTS.size()): + out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]]) + return out + + +func _initialize() -> void: + var r := RandomNumberGenerator.new() + r.seed = 21 + var arts := archetypes() + var n := 30 + var results := {} + for a in arts: + var wins := 0 + for i in n: + var b: Array = arts[r.randi_range(0, arts.size() - 1)] + var deck_a := Game.build_opponent_deck_rated(r, a, 1.6) + var deck_b := Game.build_opponent_deck_rated(r, b, 1.6) + var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x") + var g := Game.new_battle_with(deck_a, [opp], r) + var guard := 0 + while g.winner == "" and guard < 300: + AI.take_turn(g, g.current()) + guard += 1 + if g.winner == "human": + wins += 1 + results["+".join(a)] = wins * 100 / n + print("%-14s %3d%%" % ["+".join(a), wins * 100 / n]) + quit() diff --git a/tests/strength_probe.gd.uid b/tests/strength_probe.gd.uid new file mode 100644 index 0000000..4c2457c --- /dev/null +++ b/tests/strength_probe.gd.uid @@ -0,0 +1 @@ +uid://d4m21t7od3kdm diff --git a/tests/test_runner.gd b/tests/test_runner.gd index 33acbc3..e1f023e 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -106,6 +106,8 @@ func _initialize() -> void: test_neue_fallen() test_kapitel_zwei_und_merkmalwahl() test_herausforderung_und_bestenliste() + test_deckstaerke_und_schwierigkeit() + test_starter_gegen_passende_gegner() print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) quit(1 if failed > 0 else 0) @@ -456,7 +458,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() == 39, "geladen") + check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 41, "geladen") for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) @@ -1197,3 +1199,65 @@ func test_herausforderung_und_bestenliste() -> void: check(text.begins_with("karte;seltenheit;gespielt;partien;siege") and "beste_serie;5" in text, "CSV-Inhalt") DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) Profile.today_override = "" + + +func test_deckstaerke_und_schwierigkeit() -> void: + check(is_equal_approx(Cards.card_value(C("f_kobold")), 1.0) and is_equal_approx(Cards.card_value(C("ff_phoenix")), 4.6), "Kartenwert aus Seltenheit, Schlüsselwörtern, Effekten") + var starter := Profile.starter().deck_cards() + var rs := Game.deck_rating(starter) + check(rs > 1.2 and rs < 1.5, "Starter mit zwei seltenen Karten bei etwa 1,3 (%.2f)" % rs) + check(Profile.starter().deck_valid()[0] and starter.any(func(c): return c.id == "f_drache") and starter.any(func(c): return c.id == "e_baum"), "Feuerdrache und Uralter Baum im Starterdeck") + var r := RandomNumberGenerator.new() + r.seed = 5 + for target in [1.2, 1.6, 2.2, 2.6]: + for elems in [["Feuer"], ["Wasser", "Luft"]]: + var d := Game.build_opponent_deck_rated(r, elems, target) + check(Game.validate_deck(d)[0] and absf(Game.deck_rating(d) - target) <= 0.25, "Zielstärke %.1f für %s erreicht (%.2f)" % [target, "+".join(elems), Game.deck_rating(d)]) + var strong := Game.build_opponent_deck_rated(r, ["Feuer", "Luft"], 3.0) + check(Game.deck_rating(strong) >= 2.8, "Zielstärke 3,0 mit zwei Elementen erreichbar (%.2f)" % Game.deck_rating(strong)) + var g := Game.new_battle(starter, 2, r, {"target_rating": 1.3}) + for o in g.opponents: + check(absf(Game.deck_rating(o.deck + o.hand) - 1.3) <= 0.3, "Gegner auf Zielstärke (%.2f)" % Game.deck_rating(o.deck + o.hand)) + check(is_equal_approx(Campaign.target_rating(0, 1.3), 1.3) and is_equal_approx(Campaign.target_rating(0, 2.5), 2.5) and is_equal_approx(Campaign.target_rating(9, 1.3), 2.6), "Kampagne: Stufe oder eigene Stärke, was höher ist") + var opps := Campaign.opponents(2, r, 1.3) + check(opps.size() == 2 and absf(Game.deck_rating(opps[0].deck) - 1.5) <= 0.25 and absf(Game.deck_rating(opps[1].deck) - 1.5) <= 0.3, "Borin und Gefolge auf 1,5") + var p := Profile.starter() + check(p.difficulty().name == "Normal" and is_equal_approx(p.target_rating(), rs), "Normal: Zielstärke gleich eigener Stärke") + p.settings.difficulty = "schwer" + check(is_equal_approx(p.target_rating(), rs * 1.2) and p.difficulty().smart, "Schwer: 1,2-fach mit Vorausschau-KI") + p.settings.difficulty = "leicht" + check(not p.difficulty().smart and is_equal_approx(p.target_rating(), rs * 0.9), "Leicht: einfache KI, 0,9-fach") + var reward := p.record_battle(true, 1, starter, -1, [], 5, {}, p.difficulty().reward) + check(reward == int(round(Profile.REWARD_WIN[1] * 0.75)), "Leicht: weniger Münzen") + var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) + check(v.settings.difficulty == "leicht", "Schwierigkeit gespeichert") + v.settings.difficulty = "unsinn" + check(v.difficulty().name == "Normal", "unbekannte Schwierigkeit fällt auf Normal zurück") + + +func test_starter_gegen_passende_gegner() -> void: + check(Game.archetypes("leicht").size() == 5 and Game.archetypes("schwer").size() == 5 and Game.archetypes().size() == 10, "Archetypen geteilt") + check("Wasser" in Game.archetypes("leicht") and "Erde+Luft" in Game.archetypes("schwer"), "schwache und starke Archetypen") + var r := RandomNumberGenerator.new() + r.seed = 6 + var starter := Profile.starter().deck_cards() + var rs := Game.deck_rating(starter) + var results := {} + for key in ["leicht", "schwer"]: + var d: Dictionary = Profile.DIFFICULTIES[key] + var wins := 0 + var games := 24 + for i in games: + var g := Game.new_battle(starter, 1, r, {"target_rating": rs * float(d.factor), "tier": d.tier}) + var arch := Cards.combo_key(Game.deck_elements(g.opponents[0].deck + g.opponents[0].hand)) + check(arch in Game.archetypes(d.tier), "Archetyp passt zur Stufe (%s)" % arch) + var guard := 0 + while g.winner == "" and guard < 300: + var p := g.current() + AI.take_turn(g, p, (not d.smart) and not p.is_human) + guard += 1 + if g.winner == "human": + wins += 1 + results[key] = wins + check(results.leicht > results.schwer, "Leicht ist leichter als Schwer (%d zu %d von 24)" % [results.leicht, results.schwer]) + check(results.leicht >= 16, "Leicht: Starter gewinnt meistens (%d von 24)" % results.leicht) diff --git a/tests/ui_test.gd b/tests/ui_test.gd index d02d03d..5a88020 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -206,6 +206,7 @@ func test_deckbau() -> void: var scr = screen() check(scr is DeckScreen, "Deckbildschirm") check(scr.deck.size() == 20, "Deck geladen") + check("Stärke" in scr.elems_label.text, "Deckstärke im Deckbau") await shot("05_deck") await click(find_button("Leeren")) check(scr.deck.is_empty(), "geleert") @@ -345,8 +346,28 @@ func start_battle(n_opp: int) -> void: func test_gegnerauswahl() -> void: await click(find_button("Einzelspieler")) - check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet") + var sel = screen() + check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") + check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt") + await click(find_button("Schwer")) + check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert") await shot("07_gegnerauswahl") + await click(find_button("1 Gegner")) + var hard = screen() + var own := Game.deck_rating(main.profile.deck_cards()) + var opp: Game.Player = hard.game.opponents[0] + var opp_rating := Game.deck_rating(opp.deck + opp.hand) + check(opp_rating >= own * 1.05 and hard.smart_ai, "Schwer: Gegnerdeck stärker (%.2f zu %.2f), Vorausschau-KI" % [opp_rating, own]) + await give_up() + await click(find_button("Einzelspieler")) + await click(find_button("Leicht")) + await click(find_button("1 Gegner")) + var easy = screen() + opp = easy.game.opponents[0] + check(Game.deck_rating(opp.deck + opp.hand) <= own * 1.0 + 0.15 and not easy.smart_ai, "Leicht: schwächeres Deck, einfache KI") + await give_up() + await click(find_button("Einzelspieler")) + await click(find_button("Normal")) await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") for n in [1, 5]: @@ -608,7 +629,7 @@ func test_netzwerk() -> void: 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: + while b.game != null and b.game.winner == "" and rounds < 2: if b.game.current() == b.perspective: await clear_mulligan(b) if b.game.current() == b.perspective: @@ -618,7 +639,9 @@ func test_netzwerk() -> void: 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) + if b.game.winner != "": + print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12))) + check(b.game.turn >= 2 and b.game.winner == "", "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") # Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen @@ -750,8 +773,12 @@ func test_kampf_abbrechen() -> void: 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(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(reiter_view) var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel) + if hint_boxes.size() != 1: + print("DEBUG Hinweis: hints=", scr.combo_hints(Cards.by_id("w_reiter")), " kinder=", scr.preview_holder.get_children().map(func(c): return c.get_class()), + " brett=", g.human.board.map(func(c): return c.name), " hovered=", get_viewport().gui_get_hovered_control(), " reiter_pos=", reiter_view.get_global_rect()) 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]) @@ -762,6 +789,9 @@ func test_kampf_abbrechen() -> void: await right_click() check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") await hover(scr.hand_views[0]) + if scr.preview_holder.get_child_count() == 0: + print("DEBUG Hover: mulligan=", scr.mulligan_box.visible, " overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), + " dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control()) 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") diff --git a/translations/ui.csv b/translations/ui.csv index 1ac8fc9..f61a8d7 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -91,3 +91,15 @@ Niederlage,Defeat Höchstens %dx dieselbe Karte,At most %dx the same card Karte nicht oft genug in der Sammlung,Not enough copies in your collection Höchstens %d Elemente im Deck,At most %d elements in a deck +Schwierigkeit,Difficulty +Leicht,Easy +Normal,Normal +Schwer,Hard +"Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s","Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s" +schwache Archetypen,weaker archetypes +starke Archetypen,stronger archetypes +alle Archetypen,all archetypes +Vorausschau,lookahead +einfach,simple +Stärke %.1f,Strength %.1f +"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects" diff --git a/translations/ui.en.translation b/translations/ui.en.translation index c2a188d466fe18e12aa06e5658d595228fe57c7e..6e1c25f0c2344db1e1efb43d4a1383346db212a4 100644 GIT binary patch delta 2109 zcmZ`)3v5(H6rJ6-@9hT`Ub|iPmDXBXmReKFucDNGX!t~oP+(h3BNA4jpLC(pmIR`* zF{!a3puuYpBk~iCM5z4YC#gyil^8%UH4qUZik|>t3K*cFThDzvk3KQH^yJ=q?wLC? zcV@QjS)JiM^^SO#NPhkD^C8rMVSvTmZ8PtY%K`tuJ#vLMzG%RY-(9=Ecp%w71^x_M zJO}=KV1eYGw&l1`J>q5?w^I76ZCso5*T8NPaUtG=v&Sa*2!5w6=E8>%9|4Zv#dEG( zazDcUEdAf5y$pL*`mD8oH<@>V(WuKANa56AhY+cN0c0`jXLckawuQ{Zi)6%1nazLP z=5uboj2GGXTKF+r%(`Y(chxa$H9^la0_|bzm5fDY+cj0$?Jbp|jaS3>PH`@F!45+~}-& zitooDz)$DB0Y9^h!yDFpP)Fmm01b8-;bEVwsX68nVhA#+Qo*X>K9TikMf*l@JjqtLw z>g{&r|D7sJJ*4G9%GFb8uRE7wn&FSA5m!3cWdFL*KdRU|_FIfkhb{+5?yA9;G5VeT z7jN(rA;j>{RM=bXS;-&{C@6+J*KGe*DKLUVjs?M$%)w2yEb9Qjw zTs^t(7-DN@X}7+3EQYD>^jVH`Y#zVp8hHL$WLWe3_SdW3*^pYvse0=E%;rc!r^ig4z;G!J#V^?b9V$VAtktlWuVtY&n5H+^>>=4GGP zq|k09L>F9o%J!db{{19|alK^CW1diOWBlrjKcVN_JrpqOQq&Vr%MV}KJ(t$I12j@K zhNQf)wD=uK*mjUZ%<+|uevDd<%m{D*;L zd51Tkc4SSA&ZI7P8da-9?`*t({)o$UXphxyC*9`?(KJPOC{(J2s7=xRJ3pM--GU{X zM?gL5WICW4w8LWr2gPQ$ERdVw4Vmqv9)4Jg;qP71xB41tn@2(;+_}M*vMSacgpM!% zfWAS=2>(+vA+i#cs*&M;9Ok-V&zzWB{x+=HZpv3OXouUNDvx=9YA9&wCaoGBS4>i*5Xg|7IthT7VOXI8{oSBdG-mQ`YQQ}fbjOgtKG zUMvzdr{6u5Xe|43!sK5Q;bn<&#iHMF=;NY9Wl2fL98QJ)Lx)2$KUEtV>gCjUIilZL zF|$}C!oGx9?@Ba2U-_m}iJU;xFsr3%AbK`^u$724H8n=-mqZtf?$ISrBt)dOdC9W6 z*81#l;ka6nm?qbik|<99!*whBv&u1%k%>lUq~bwiSGlvG;FN;ue3X0;^CI)6{tMd2 B-E9B> delta 1195 zcmYk5Ye*DP6vrK%ncbZSjZQh^uBls^Ar=<;K{Fp|7!`pJ!YCy{un!g(CP@z|G$Ko~ zBPKmCLh3`AntjL+>9LTNfkg$Sfe;Ca8HJ!lwf*nzH5~Zy|DU_(p8Gg=2NO=m^zIWH zDnf#vAD;|s0OljPG9SnNDrhoT&*Mz!Ca?wU;A5rGa!}3VgV4j^IA{bdpbcCCC>`qW zq7#DIa2MfyFccj2`=HOcXImJBf5+nq=p^^k&>tW~2nkVu0a*UxFOrW%LsxLWn(Nx2 zHuzIvGC&U43Q9mZs06Io0j|u~!k+>i;07P>g${Agau&YfMPskwClWgD5rK|40$d-T|F#>k8{SP7k|!aIXNF(gpD_im0G6HY zo9QmZ`VM#py;nS>Nj42SZ0S?kn>m$UsU|gw22OQMvPi_*l}>}E6Qg+>N04%6i2f--%-V&-!3QlFkXvYWx5P0&=K-#CeB(WTjA{w)Tthvighd4 zf#s5wmX#oMveyrpt+18NmbO2+)efn~FQ zuzCzKz0@jsC>}e