update
Some checks failed
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled

This commit is contained in:
Team3
2026-09-22 13:23:02 +02:00
parent 8944fdd017
commit dfcd3cb713
46 changed files with 1525 additions and 4141 deletions

View File

@@ -30,13 +30,21 @@ static func board_value(p: Game.Player) -> float:
return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen).
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
static func hand_value(p: Game.Player) -> float:
var v := 0.0
for c in p.hand:
v += 0.15 * (c.attack + c.health)
return v
## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv,
## die der Gegner negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden
## bremsen). Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive():
return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.02
var alive_enemies := 0
var threat := 0.0
var leader := 0.0
@@ -117,6 +125,18 @@ static func _top(list: Array, rank: Callable, n: int) -> Array:
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
var out := []
var valid_cache := {}
var afford := false
var seen := {}
for i in p.shop.size():
var offer = p.shop[i]
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
afford = true
if not seen.has(offer.id): # dasselbe Angebot zweimal zu prüfen bringt nichts
seen[offer.id] = true
out.append({"type": "buy", "slot": i, "card": offer})
# Neu würfeln lohnt nur, wenn ein Laden da ist und nichts Bezahlbares darin liegt.
if not afford and not p.shop.is_empty() and p.energy >= Game.REROLL_COST and p.hand.size() < Game.MAX_HAND:
out.append({"type": "reroll"})
for card in p.hand:
if not game.can_play(p, card)[0]:
continue
@@ -144,6 +164,14 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -
valid = valid_cache[key]
for t in valid:
out.append({"type": "play", "card": card, "target": t})
var pairs := []
for i in p.board.size():
for j in range(i + 1, p.board.size()):
if game.can_fuse(p, p.board[i], p.board[j])[0]:
pairs.append({"type": "fuse", "creature": p.board[i], "other": p.board[j]})
if prune: # Verschmelzen lohnt vor allem bei schwachen Wesen
pairs = _top(pairs, func(a): return -float(_value(a.creature) + _value(a.other)), MAX_TARGETS)
out.append_array(pairs)
for cr in p.board:
if not cr.can_attack():
continue
@@ -165,10 +193,17 @@ static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var target = null
if action.get("target") != null:
target = map_target(g2, game, action.target)
if action.type == "play":
g2.play_card(p2, action.card, target)
else:
g2.attack(g2.find_creature(action.creature.uid), target)
match action.type:
"play":
g2.play_card(p2, action.card, target)
"buy":
g2.buy(p2, int(action.slot))
"reroll":
g2.reroll(p2)
"fuse":
g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid))
_:
g2.attack(g2.find_creature(action.creature.uid), target)
static func map_target(g2: Game, game: Game, t):
@@ -182,31 +217,26 @@ static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary
var out := action.duplicate()
if action.has("creature"):
out["creature"] = game.find_creature(action.creature.uid)
if action.has("other"):
out["other"] = game.find_creature(action.other.uid)
if action.get("target") != null:
out["target"] = map_target(game, copy, action.target)
return out
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
static func mulligan_cards(p: Game.Player) -> Array:
return p.hand.filter(func(c): return c.cost >= 5)
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)}
var t0 := Time.get_ticks_msec()
var base := evaluate(game, p) + 0.01
var firsts := [] # {action, game, score}
for action in candidate_actions(game, p, true):
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break
var g2 := game.clone_for(p)
var g2 := game.clone()
apply_on_clone(g2, game, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
firsts.sort_custom(func(a, b): return a.score > b.score)
@@ -223,7 +253,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
break
count += 1
var g3 := g2.clone_for(p2)
var g3 := g2.clone()
apply_on_clone(g3, g2, nxt)
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
if score > best_score:
@@ -240,7 +270,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
# --- Gierige Vergleichs-KI -------------------------------------------------------
static func _kills(attacker, defender) -> bool:
return not defender.shield and defender.health <= attacker.power()
return not defender.shield and defender.health <= attacker.power() + Game.matchup_bonus(attacker, defender)
static func _value(c) -> int:
@@ -276,8 +306,15 @@ static func _weakest(heroes: Array):
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)}
# Gierig: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen.
var best := -1
for k in p.shop.size():
var offer = p.shop[k]
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
if best < 0 or offer.cost > p.shop[best].cost:
best = k
if best >= 0:
return {"type": "buy", "slot": best}
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty():
Game.shuffle(playable, game.rng)
@@ -360,10 +397,14 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void
match action.type:
"play":
game.play_card(p, action.card, action.target)
"buy":
game.buy(p, int(action.slot))
"reroll":
game.reroll(p)
"attack":
game.attack(action.creature, action.target)
"mulligan":
game.mulligan(p, action.cards)
"fuse":
game.fuse(p, action.creature, action.other)
_:
game.end_turn()