diff --git a/README.md b/README.md index ff5456b..f4aaf4f 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,15 @@ Der Spielstand liegt unter `user://spielstand.json` - 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 +- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen +- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor +- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben ## Tests `tests/run_headless.sh` führt Import und Headless-Prüfungen aus. `.github/workflows/tests.yml` läuft beides in CI: die Headless-Prüfungen direkt, den Fenster-Test unter Xvfb (lokal ohne Xvfb nicht geprüft). +`DINGER_FAST=1` lässt den Fenster-Test ohne Hotseat, Netzwerk und lange Kämpfe laufen (etwa ein Drittel der Zeit). Statistiken aus Spieltests lassen sich unter Erfolge als CSV exportieren. ``` @@ -44,7 +48,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/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in Game.ARCHETYPE_STRENGTH) +godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH) DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots ``` @@ -60,7 +64,9 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe ## Aufbau - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` -- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Gegner, Hotseat +- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Hotseat +- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen +- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen - `scripts/campaign.gd` – Kampagnenstufen diff --git a/data/karten.json b/data/karten.json index 8b2eca9..059f6ee 100644 --- a/data/karten.json +++ b/data/karten.json @@ -133,7 +133,7 @@ "cost": 2, "rarity": "gewöhnlich", "kind": "Wesen", - "attack": 2, + "attack": 3, "health": 3 }, { @@ -162,7 +162,7 @@ "cost": 3, "rarity": "gewöhnlich", "kind": "Wesen", - "attack": 2, + "attack": 3, "health": 4, "effects": [ { @@ -180,7 +180,7 @@ "cost": 5, "rarity": "selten", "kind": "Wesen", - "attack": 4, + "attack": 5, "health": 6, "effects": [ { @@ -477,7 +477,7 @@ "cost": 6, "rarity": "episch", "kind": "Wesen", - "attack": 5, + "attack": 6, "health": 7, "effects": [ { @@ -1131,7 +1131,7 @@ "cost": 4, "rarity": "gewöhnlich", "kind": "Wesen", - "attack": 3, + "attack": 4, "health": 6, "keywords": [ "Wächter" @@ -1586,7 +1586,7 @@ "rarity": "gewöhnlich", "kind": "Wesen", "attack": 3, - "health": 3 + "health": 4 }, { "id": "w_taucher", @@ -1597,7 +1597,7 @@ "cost": 4, "rarity": "gewöhnlich", "kind": "Wesen", - "attack": 3, + "attack": 4, "health": 4, "effects": [ { @@ -1891,6 +1891,65 @@ } ] }, + { + "id": "w_ebbe", + "name": "Ebbe", + "elements": [ + "Wasser" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 1 + }, + { + "kind": "freeze", + "target": "enemy_creature" + } + ] + }, + { + "id": "w_tiefenstoss", + "name": "Tiefenstoß", + "elements": [ + "Wasser" + ], + "cost": 2, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 3 + } + ] + }, + { + "id": "w_strudel", + "name": "Strudel", + "elements": [ + "Wasser" + ], + "cost": 3, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "bounce", + "target": "enemy_creature" + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, { "id": "fff_sonne", "name": "Kern der Sonne", diff --git a/scripts/ai.gd b/scripts/ai.gd index 37d8bb1..c51726f 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -15,7 +15,9 @@ static func creature_value(c: Game.Creature) -> float: if c.attacks_per_turn() == 2: v += c.power() if c.frozen: - v -= c.power() * 0.5 + v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht + if not c.ready: + v -= c.power() * 0.25 return v diff --git a/scripts/cards.gd b/scripts/cards.gd index 82e4e58..5cc7315 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -35,7 +35,7 @@ const FIELD_COMBO_TEXT := { "Sturm": "Ziehe 1 Karte", "Sand": "Das neue Wesen erhält Schild", } -const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)." +const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern." const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] const RARITY_COLORS := { "gewöhnlich": Color("aaaaaa"), diff --git a/scripts/deckgen.gd b/scripts/deckgen.gd new file mode 100644 index 0000000..676de01 --- /dev/null +++ b/scripts/deckgen.gd @@ -0,0 +1,128 @@ +class_name DeckGen +## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln). + +const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3} +# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner). +const RESONANCE_STEPS := [0, 1, 2, 3, 3] + +# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, tests/strength_probe.gd). +const ARCHETYPE_STRENGTH := { + "Feuer": 36, "Wasser": 30, "Erde": 63, "Luft": 50, + "Feuer+Wasser": 46, "Feuer+Erde": 70, "Feuer+Luft": 40, + "Wasser+Erde": 46, "Wasser+Luft": 20, "Erde+Luft": 46, +} + + + +## 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 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() < Game.DECK_SIZE: + 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 + + +## 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) >= Game.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)] + + +## 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 + + +## 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 = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.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 Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier") + + +## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests). +static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float: + var v := Cards.card_value(c) + if stats.has(c.id): + var st: Dictionary = stats[c.id] + if int(st.games) >= 10: + v *= 0.5 + float(st.wins) / float(st.games) + return v + + +## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten. +static func curve_penalty(cards: Array) -> float: + var cheap := 0 + var expensive := 0 + for c in cards: + if c.cost <= 2: + cheap += 1 + if c.cost >= 5: + expensive += 1 + return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4) + + +## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5). +static func deck_rating(cards: Array, stats: Dictionary = {}) -> float: + if cards.is_empty(): + return 0.0 + var sum := 0.0 + for c in cards: + sum += card_value(c, stats) + return sum / cards.size() - curve_penalty(cards) diff --git a/scripts/deckgen.gd.uid b/scripts/deckgen.gd.uid new file mode 100644 index 0000000..ed55f40 --- /dev/null +++ b/scripts/deckgen.gd.uid @@ -0,0 +1 @@ +uid://ev8t5ar278vl diff --git a/scripts/game.gd b/scripts/game.gd index 523b90a..4257a4c 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -43,7 +43,6 @@ static func make_rng() -> RandomNumberGenerator: const DECK_SIZE := 20 const MAX_COPIES := 3 const MAX_DECK_ELEMENTS := 2 -const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3} class Creature extends RefCounted: @@ -475,7 +474,12 @@ func summon(p: Player, card: Cards.Card) -> void: update_resonance() -## Resonanz: +1 Angriff je anderem eigenen Wesen, das ein Element teilt (höchstens +4). +## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3. +static func resonance_for(partners: int) -> int: + return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)] + + +## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for). func update_resonance() -> void: for p in players: for c in p.board: @@ -483,7 +487,7 @@ func update_resonance() -> void: for o in p.board: if o != c and o.card.elements.any(func(e): return e in c.card.elements): bonus += 1 - c.resonance = mini(bonus, RESONANCE_MAX) * (2 if "resonanz_doppelt" in p.perks else 1) + c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1) func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void: @@ -643,103 +647,14 @@ func check_end() -> void: say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name) -# --- Zustand für das Netzwerk ------------------------------------------------- +# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------ -## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl. func snapshot(viewer: int) -> Dictionary: - var ps := [] - for i in players.size(): - var p: Player = players[i] - var mine := i == viewer - var board := [] - for c in p.board: - board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance, - "health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen, - "ready": c.ready, "attacks_left": c.attacks_left}) - ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life, - "energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus, - "order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done, - "hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(), - "deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(), - "board": board}) - var evs := [] - for ev in events: - var e: Dictionary = ev.duplicate() - if e.has("card"): - e["card"] = e.card.id - evs.append(e) - return {"players": ps, "human": players.find(human), "turn": turn, "current_idx": current_idx, - "winner": winner, "winner_player": players.find(winner_player) if winner_player != null else -1, - "next_uid": next_uid, "log": log.slice(maxi(0, log.size() - 80)), "events": evs} + return NetState.snapshot(self, viewer) -## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt. static func from_snapshot(d: Dictionary) -> Game: - var placeholder: Cards.Card = Cards.all()[0] - var list := [] - for pd in d.players: - var p := Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title) - p.max_life = int(pd.max_life) - p.energy = int(pd.energy) - p.max_energy = int(pd.max_energy) - p.fatigue = int(pd.fatigue) - p.start_bonus = int(pd.start_bonus) - p.order = int(pd.order) - p.perks = Array(pd.perks) - p.turns = int(pd.turns) - p.mulligan_done = bool(pd.mulligan_done) - if pd.hand is Array: - for cid in pd.hand: - p.hand.append(Cards.by_id(cid)) - else: - for i in int(pd.hand): - p.hand.append(placeholder) - for i in int(pd.deck): - p.deck.append(placeholder) - if pd.traps is Array: - for cid in pd.traps: - p.traps.append(Cards.by_id(cid)) - else: - for i in int(pd.traps): - p.traps.append(placeholder) - for cd in pd.board: - var c := Creature.new(Cards.by_id(cd.card), p, int(cd.uid)) - c.attack = int(cd.attack) - c.resonance = int(cd.resonance) - c.health = int(cd.health) - c.max_health = int(cd.max_health) - c.shield = bool(cd.shield) - c.frozen = bool(cd.frozen) - c.ready = bool(cd.ready) - c.attacks_left = int(cd.attacks_left) - p.board.append(c) - list.append(p) - var hi := int(d.human) - var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new()) - g.players = list - g.turn = int(d.turn) - g.current_idx = int(d.current_idx) - g.winner = d.winner - g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null - g.next_uid = int(d.next_uid) - for line in d.log: - g.log.append(str(line)) - for ev in d.events: - var e: Dictionary = ev.duplicate() - if e.has("card"): - e["card"] = Cards.by_id(e.card) - for key in ["player", "uid", "count", "amount"]: - if e.has(key): - e[key] = int(e[key]) - for key in ["target", "attacker", "source"]: - if e.has(key): - var r: Dictionary = e[key] - if r.has("uid"): - r["uid"] = int(r.uid) - if r.has("index"): - r["index"] = int(r.index) - g.events.append(e) - return g + return NetState.from_snapshot(d) # --- Deckregeln und Gegner --------------------------------------------------- @@ -778,106 +693,29 @@ static func validate_deck(cards: Array, owned = null) -> Array: return [true, ""] -## 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 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 = _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 - - -## 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)] - - -## 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} +# --- Deckstärke und Gegner (siehe DeckGen) -------------------------------------- + +static func deck_rating(cards: Array, stats: Dictionary = {}) -> float: + return DeckGen.deck_rating(cards, stats) + + +static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array: + return DeckGen.build_opponent_deck(r, elements, bias) + + +static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array: + return DeckGen.build_opponent_deck_rated(r, elements, target, tolerance) + + +static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE, target: float = 0.0, tier: String = "") -> Player: + return DeckGen.random_opponent(r, index, life, target, tier) + + +static func archetypes(tier: String = "") -> Array: + return DeckGen.archetypes(tier) + + 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: diff --git a/scripts/netstate.gd b/scripts/netstate.gd new file mode 100644 index 0000000..a0d6a59 --- /dev/null +++ b/scripts/netstate.gd @@ -0,0 +1,99 @@ +class_name NetState +## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln). + + +## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl. +static func snapshot(g: Game, viewer: int) -> Dictionary: + var ps := [] + for i in g.players.size(): + var p: Game.Player = g.players[i] + var mine := i == viewer + var board := [] + for c in p.board: + board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance, + "health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen, + "ready": c.ready, "attacks_left": c.attacks_left}) + ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life, + "energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus, + "order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done, + "hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(), + "deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(), + "board": board}) + var evs := [] + for ev in g.events: + var e: Dictionary = ev.duplicate() + if e.has("card"): + e["card"] = e.card.id + evs.append(e) + return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx, + "winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1, + "next_uid": g.next_uid, "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs} + + +## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt. +static func from_snapshot(d: Dictionary) -> Game: + var placeholder: Cards.Card = Cards.all()[0] + var list := [] + for pd in d.players: + var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title) + p.max_life = int(pd.max_life) + p.energy = int(pd.energy) + p.max_energy = int(pd.max_energy) + p.fatigue = int(pd.fatigue) + p.start_bonus = int(pd.start_bonus) + p.order = int(pd.order) + p.perks = Array(pd.perks) + p.turns = int(pd.turns) + p.mulligan_done = bool(pd.mulligan_done) + if pd.hand is Array: + for cid in pd.hand: + p.hand.append(Cards.by_id(cid)) + else: + for i in int(pd.hand): + p.hand.append(placeholder) + for i in int(pd.deck): + p.deck.append(placeholder) + if pd.traps is Array: + for cid in pd.traps: + p.traps.append(Cards.by_id(cid)) + else: + for i in int(pd.traps): + p.traps.append(placeholder) + for cd in pd.board: + var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid)) + c.attack = int(cd.attack) + c.resonance = int(cd.resonance) + c.health = int(cd.health) + c.max_health = int(cd.max_health) + c.shield = bool(cd.shield) + c.frozen = bool(cd.frozen) + c.ready = bool(cd.ready) + c.attacks_left = int(cd.attacks_left) + p.board.append(c) + list.append(p) + var hi := int(d.human) + var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new()) + g.players = list + g.turn = int(d.turn) + g.current_idx = int(d.current_idx) + g.winner = d.winner + g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null + g.next_uid = int(d.next_uid) + for line in d.log: + g.log.append(str(line)) + for ev in d.events: + var e: Dictionary = ev.duplicate() + if e.has("card"): + e["card"] = Cards.by_id(e.card) + for key in ["player", "uid", "count", "amount"]: + if e.has(key): + e[key] = int(e[key]) + for key in ["target", "attacker", "source"]: + if e.has(key): + var r: Dictionary = e[key] + if r.has("uid"): + r["uid"] = int(r.uid) + if r.has("index"): + r["index"] = int(r.index) + g.events.append(e) + return g diff --git a/scripts/netstate.gd.uid b/scripts/netstate.gd.uid new file mode 100644 index 0000000..aab4c27 --- /dev/null +++ b/scripts/netstate.gd.uid @@ -0,0 +1 @@ +uid://c3ao1mpgbrter diff --git a/scripts/profile.gd b/scripts/profile.gd index f76cd18..f6ac31b 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -70,7 +70,7 @@ var card_stats := {} # id -> {"played", "games", "wins"} var booster_since_mythic := 0 var tutorial_done := false var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) -var records := {"streak": 0, "best_streak": 0, "fastest_win": 0} +var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0} var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung ## Das aktive Deck (Kartenliste des gewählten Slots). @@ -174,7 +174,7 @@ static func from_dict(d: Dictionary) -> Profile: p.perk = "" var rc = d.get("records", {}) p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)), - "fastest_win": int(rc.get("fastest_win", 0))} + "fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0))} p.challenge_week = int(d.get("challenge_week", -1)) return p @@ -380,11 +380,17 @@ func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, pla stats.games += 1 if won: records.streak += 1 + records.loss_streak = 0 records.best_streak = maxi(records.best_streak, records.streak) if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win): records.fastest_win = turns else: records.streak = 0 + records.loss_streak += 1 + var tip := suggest_difficulty() + if tip != "" and stage < 0 and challenge.is_empty(): + notices.append("Tipp: %s. Schwierigkeit %s?" % [ + "%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name]) for cid in played_ids: _stat(cid).played += 1 var seen := {} @@ -477,10 +483,71 @@ func difficulty() -> Dictionary: return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal) +## Vorschlag: nach drei Siegen in Folge eine Stufe rauf, nach drei Niederlagen runter. "" = kein Vorschlag. +func suggest_difficulty() -> String: + var order := ["leicht", "normal", "schwer"] + var i := order.find(str(settings.get("difficulty", "normal"))) + if records.streak >= 3 and i < order.size() - 1: + return order[i + 1] + if records.loss_streak >= 3 and i > 0: + return order[i - 1] + return "" + + +## Eigene Deckstärke, mit Kartenstatistik gewichtet. +func deck_rating(cards: Array = []) -> float: + var deck_used: Array = cards if not cards.is_empty() else deck_cards() + return Game.deck_rating(deck_used, card_stats) + + ## 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) + return deck_rating(cards) * float(difficulty().factor) + + +## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt. +## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht. +func improve_deck_with(cid: String, dry_run: bool = false) -> String: + if collection.get(cid, 0) <= 0: + return "Karte nicht in der Sammlung" + var card := Cards.by_id(cid) + if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES): + return "Schon so oft im Deck wie möglich" + var elems := Game.deck_elements(deck_cards()) + for e in card.elements: + if not (e in elems): + elems.append(e) + if elems.size() > Game.MAX_DECK_ELEMENTS: + return "Passt nicht zu den Elementen des Decks" + var weakest := "" + var weakest_value := Cards.card_value(card) + for other in deck: + if other == cid: + continue + var v := Cards.card_value(Cards.by_id(other)) + if v < weakest_value: + weakest_value = v + weakest = other + if weakest == "": + return "Keine schwächere Karte im Deck" + if dry_run: + return "" + var trial: Array = deck.duplicate() + trial[trial.find(weakest)] = cid + var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection) + if not ok[0]: + return ok[1] + deck = trial + return "" + + +## Karten aus new_ids, die das aktive Deck verbessern würden. +func deck_suggestions(new_ids: Array) -> Array: + var out := [] + for cid in new_ids: + if not (cid in out) and improve_deck_with(cid, true) == "": + out.append(cid) + return out func perk_unlocked() -> bool: diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index d61a2b2..9d0e9d3 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -27,6 +27,7 @@ var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) var mulligan_selected: Array = [] var tutorial_step := 0 var challenge := {} # wöchentliche Herausforderung oder leer +var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner 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 @@ -129,6 +130,12 @@ static func guided() -> BattleScreen: return b +static func against(opps: Array) -> BattleScreen: + var b := BattleScreen.new(opps.size(), -1) + b.preset_opponents = opps + return b + + static func weekly(ch: Dictionary) -> BattleScreen: var b := BattleScreen.new(int(ch.n_opp), -1) b.challenge = ch @@ -181,6 +188,8 @@ func _ready() -> void: elif stage >= 0: var r := Game.make_rng() game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules()) + elif not preset_opponents.is_empty(): + game = Game.new_battle_with(app.profile.deck_cards(), preset_opponents, null, 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() diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd index 4f50f69..9ce0a81 100644 --- a/scripts/ui/booster_screen.gd +++ b/scripts/ui/booster_screen.gd @@ -12,6 +12,8 @@ var coins_label: Label var msg_label: Label var b_buy: Button var b_all: Button +var b_improve: Button +var suggestion := "" var slots: Control @@ -52,6 +54,8 @@ func _ready() -> void: msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center") b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628")) b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all) + b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c")) + b_improve.visible = false Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new())) refresh() @@ -95,11 +99,37 @@ func reveal_all() -> void: refresh() +## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste). +func improve() -> void: + if suggestion == "": + return + var err: String = app.profile.improve_deck_with(suggestion) + if err == "": + app.save() + msg_label.text = "%s ist jetzt im Deck %s" % [Cards.by_id(suggestion).name, app.profile.deck_name()] + msg_label.add_theme_color_override("font_color", Style.GREEN) + suggestion = "" + else: + msg_label.text = err + msg_label.add_theme_color_override("font_color", Style.RED) + refresh() + + func refresh() -> void: var p: Profile = app.profile coins_label.text = "Münzen: %d" % p.coins b_buy.disabled = p.coins < Profile.BOOSTER_COST b_all.disabled = cards.is_empty() or revealed.size() >= 5 + # Deck-Vorschlag, sobald alle Karten offen liegen + suggestion = "" + if not cards.is_empty() and revealed.size() >= 5: + var ids: Array = cards.map(func(c): return c.id) + var better: Array = p.deck_suggestions(ids) + if not better.is_empty(): + suggestion = better[0] + b_improve.visible = suggestion != "" + if suggestion != "": + b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).name, p.deck_name()] Style.clear(slots) for i in 5: var r := slot_rect(i) diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 3bd40e3..6d5c96a 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -63,7 +63,8 @@ func build() -> void: var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2) var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk])) - Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, perks_text], 12, Style.MUTED, "left", false, 360) + var target := Campaign.target_rating(i, p.target_rating()) + Style.clabel(r, "%s · Stärke %.1f · %d Münzen\n★ %s" % [table, target, st.reward, perks_text], 12, Style.MUTED, "left", false, 360) if done: Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100) Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i)) diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index dd100a5..2835244 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,46 +1,66 @@ class_name OpponentSelectScreen extends Control -## Einzelspieler: Schwierigkeit und Gegnerzahl. Gegnerdecks werden auf die eigene Deckstärke gebaut. +## Einzelspieler: Schwierigkeit, Gegnerzahl und Vorschau der Gegner (Element, Stärke). var app var rating_label: Label +var tip_label: Label var diff_buttons := {} +var previews := {} # n -> Array von Game.Player +var preview_labels := {} +var b_tip: Button func _ready() -> void: - var col := Style.column(self, 900, 40, 12) + var col := Style.column(self, 1000, 30, 10) Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) - 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, "Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben. Du gewinnst, wenn alle anderen besiegt sind." % Game.START_LIFE, 15, Style.MUTED) 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) + diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k)) + rating_label = Style.clabel(col, "", 14, Style.MUTED) + var tr_row := Style.row(col, 10) + tr_row.alignment = BoxContainer.ALIGNMENT_CENTER + tip_label = Style.clabel(tr_row, "", 14, Style.GOLD, "right", false, 520) + b_tip = Style.cbutton(tr_row, "Übernehmen", Vector2(140, 32), take_tip) + Style.spacer(col, 2) for i in range(1, Game.MAX_OPPONENTS + 1): var n := i - var r := Style.row(col, 20) + var r := Style.row(col, 16) r.alignment = BoxContainer.ALIGNMENT_CENTER - 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, 10) - var br := Style.row(col) + Style.cbutton(r, "%d Gegner" % n, Vector2(150, 46), func(): start(n), Color("285a3c")) + var info := ("Duell" if n == 1 else "%d am Tisch" % (n + 1)) + " · %d Münzen" % Profile.REWARD_WIN[n] + Style.clabel(r, info, 13, Style.MUTED, "left", false, 150) + preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600) + Style.spacer(col, 4) + var br := Style.row(col, 16) br.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) - refresh() + Style.cbutton(br, "Neu würfeln", Vector2(150, 40), func(): refresh(true)) + Style.cbutton(br, "Zurück", Vector2(150, 40), func(): app.show_screen(MenuScreen.new())) + refresh(true) func set_difficulty(key: String) -> void: app.profile.settings.difficulty = key app.save() - refresh() + refresh(true) -func refresh() -> void: +func take_tip() -> void: + var tip: String = app.profile.suggest_difficulty() + if tip != "": + set_difficulty(tip) + + +func start(n: int) -> void: + app.show_screen(BattleScreen.against(previews[n])) + + +## Baut die Gegner für jede Anzahl vor und zeigt Element und Stärke; der Kampf nutzt genau diese. +func refresh(reroll: bool = false) -> void: var p: Profile = app.profile var d := p.difficulty() for key in diff_buttons: @@ -50,7 +70,23 @@ func refresh() -> void: 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")] + p.deck_rating(), p.target_rating(), arche, d.reward, tr("Vorausschau") if d.smart else tr("einfach")] + var tip := p.suggest_difficulty() + tip_label.text = (tr("Tipp: %s") % (("%d Siege in Folge, versuche %s" % [p.records.streak, Profile.DIFFICULTIES[tip].name]) if p.records.streak >= 3 + else ("%d Niederlagen in Folge, versuche %s" % [p.records.loss_streak, Profile.DIFFICULTIES[tip].name]))) if tip != "" else "" + b_tip.visible = tip != "" + if reroll: + var r := Game.make_rng() + for n in range(1, Game.MAX_OPPONENTS + 1): + var opps := [] + for i in n: + opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier))) + previews[n] = opps + for n in previews: + var parts: Array[String] = [] + for o in previews[n]: + parts.append("%s (%.1f)" % [o.title, Game.deck_rating(o.deck)]) + preview_labels[n].text = ", ".join(parts) func _input(event: InputEvent) -> void: diff --git a/tests/test_runner.gd b/tests/test_runner.gd index e1f023e..bc7e8ee 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -108,12 +108,14 @@ func _initialize() -> void: test_herausforderung_und_bestenliste() test_deckstaerke_und_schwierigkeit() test_starter_gegen_passende_gegner() + test_kurve_statistik_vorschlag_verbesserung() + test_wasser_antworten() print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) quit(1 if failed > 0 else 0) func test_kartenpool() -> void: - check(Cards.all().size() == 106, "106 Karten") + check(Cards.all().size() == 109, "109 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) @@ -458,7 +460,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() == 41, "geladen") + check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 42, "geladen") for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) @@ -510,7 +512,7 @@ func test_resonanz() -> void: put(h, "f_kobold") put(h, "f_kobold") g.update_resonance() - check(h.board[0].power() == 7, "Resonanz höchstens +4") + check(h.board[0].power() == 6 and Game.resonance_for(1) == 1 and Game.resonance_for(3) == 3 and Game.resonance_for(4) == 3, "Resonanz höchstens +3") for i in [1, 3, 4, 5, 6]: h.board[i].health = 0 g.cleanup() @@ -1214,7 +1216,7 @@ func test_deckstaerke_und_schwierigkeit() -> void: 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)) + check(Game.deck_rating(strong) >= 2.7, "Zielstärke 3,0 mit zwei Elementen nahezu 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)) @@ -1261,3 +1263,59 @@ func test_starter_gegen_passende_gegner() -> void: 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) + + +func test_kurve_statistik_vorschlag_verbesserung() -> void: + var starter := Profile.starter().deck_cards() + check(is_zero_approx(DeckGen.curve_penalty(starter)), "Starterkurve ohne Abzug") + var teuer := [] + for i in 20: + teuer.append(Cards.by_id("f_zunge")) + check(DeckGen.curve_penalty(teuer) >= 0.4 - 0.001 and Game.deck_rating(teuer) < 1.0, "teures Deck: Kurvenabzug") + var stats := {"f_kobold": {"played": 30, "games": 20, "wins": 16}} + check(DeckGen.card_value(Cards.by_id("f_kobold"), stats) > Cards.card_value(Cards.by_id("f_kobold")), "gute Statistik hebt den Wert") + check(Game.deck_rating(starter, stats) > Game.deck_rating(starter), "Deckstärke mit Statistik") + var few := {"f_kobold": {"played": 3, "games": 3, "wins": 3}} + check(is_equal_approx(DeckGen.card_value(Cards.by_id("f_kobold"), few), 1.0), "zu wenig Partien: Basiswert") + # Vorschlag nach Serien + var p := Profile.starter() + var mono := starter + check(p.suggest_difficulty() == "", "kein Vorschlag am Anfang") + for i in 3: + p.record_battle(true, 1, mono) + check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei Siege: Schwer vorschlagen") + p.settings.difficulty = "schwer" + p.take_notices() + for i in 3: + p.record_battle(false, 1, mono) + check(p.records.loss_streak == 3 and p.suggest_difficulty() == "normal", "drei Niederlagen: Normal vorschlagen") + p.settings.difficulty = "leicht" + check(p.suggest_difficulty() == "", "auf Leicht kein Abstieg") + # Deck verbessern + var q := Profile.starter() + check(q.improve_deck_with("ff_phoenix", true) != "", "nicht besessen") + q.collection["ff_phoenix"] = 1 + check(q.improve_deck_with("ff_phoenix", true) == "", "Phönix passt (Feuer)") + q.collection["ww_leviathan"] = 1 + check(q.improve_deck_with("ww_leviathan", true) != "", "Leviathan passt nicht (drittes Element)") + check(q.deck_suggestions(["ff_phoenix", "ww_leviathan", "f_kobold"]) == ["ff_phoenix"], "Vorschläge gefiltert") + var before := q.deck.duplicate() + check(q.improve_deck_with("ff_phoenix") == "" and q.deck.count("ff_phoenix") == 1 and q.deck.size() == 20 and q.deck_valid()[0], "Phönix eingetauscht") + check(Game.deck_rating(q.deck_cards()) > Game.deck_rating(before.map(func(id): return Cards.by_id(id))), "Deck stärker") + check(q.improve_deck_with("ff_phoenix", true) != "", "zweite Kopie nicht möglich") + + +func test_wasser_antworten() -> void: + var s := setup(["w_ebbe", "w_tiefenstoss", "w_strudel"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var t := put(o, "e_troll") + g.play_card(h, C("w_ebbe"), t) + check(t.health == 5 and t.frozen, "Ebbe: 1 Schaden und Eis") + g.play_card(h, C("w_tiefenstoss"), t) + check(t.health == 2, "Tiefenstoß: 3 Schaden") + var hand := h.hand.size() + g.play_card(h, C("w_strudel"), t) + check(o.board.is_empty() and C("e_troll") in o.hand and h.hand.size() == hand, "Strudel: zurück auf die Hand, eine Karte gezogen") + check(C("w_ebbe").rarity == "gewöhnlich" and Profile.starter().collection.has("w_ebbe"), "Ebbe in der Startsammlung") diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 5a88020..0a18d76 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -153,9 +153,12 @@ func _ready() -> void: 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_herausforderung, - test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]: + var fast := OS.get_environment("DINGER_FAST") != "" + var suite := [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, + test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_herausforderung] + if not fast: + suite += [test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen] + for t in suite: await ensure_menu() await t.call() for suffix in ["", ".bak", ".corrupt"]: @@ -196,6 +199,13 @@ func test_booster() -> void: check(scr.revealed.size() == 5, "alle aufgedeckt") await shot("04_booster_offen") check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") + var ids: Array = scr.cards.map(func(c): return c.id) + var better: Array = main.profile.deck_suggestions(ids) + check(scr.b_improve.visible == (not better.is_empty()), "Deck-Vorschlag nur bei passender Karte") + if scr.b_improve.visible: + var deck_before: Array = main.profile.deck.duplicate() + await click(scr.b_improve) + check(main.profile.deck != deck_before and main.profile.deck_valid()[0] and "im Deck" in scr.msg_label.text, "Karte ins Deck übernommen") 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ü") @@ -349,15 +359,18 @@ func test_gegnerauswahl() -> void: var sel = screen() check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt") + check(sel.preview_labels[3].text.count("(") == 3 and sel.previews[3].size() == 3, "Vorschau zeigt drei Gegner mit Stärke") 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") + var previewed: Array = sel.previews[1].map(func(o): return o.title) await shot("07_gegnerauswahl") await click(find_button("1 Gegner")) var hard = screen() + check(hard.game.opponents.map(func(o): return o.title) == previewed, "Kampf nutzt die vorgeschauten Gegner") 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]) + check(opp_rating >= own * 1.05 - 0.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")) @@ -368,6 +381,13 @@ func test_gegnerauswahl() -> void: await give_up() await click(find_button("Einzelspieler")) await click(find_button("Normal")) + main.profile.records.streak = 3 + screen().refresh() + check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei Siegen") + await click(find_button("Übernehmen")) + check(main.profile.settings.difficulty == "schwer", "Vorschlag übernommen") + main.profile.records.streak = 0 + await click(find_button("Normal")) await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") for n in [1, 5]: diff --git a/translations/ui.csv b/translations/ui.csv index f61a8d7..cb35698 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -103,3 +103,7 @@ 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" +Tipp: %s,Tip: %s +Übernehmen,Apply +Neu würfeln,Reroll +Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s) diff --git a/translations/ui.en.translation b/translations/ui.en.translation index 6e1c25f..d847f76 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ