Files
pirate-dice/scripts/ai.gd
2026-09-16 18:35:59 +02:00

190 lines
6.5 KiB
GDScript

extends RefCounted
class_name DiceAI
## Entscheidungslogik für KI-Spieler.
static func _pmf(n: int, k: int, p: float) -> float:
var c := 1.0
for i in k:
c = c * float(n - i) / float(i + 1)
return c * pow(p, k) * pow(1.0 - p, n - k)
## P(X >= k) für X ~ Bin(n, p)
static func tail(n: int, k: int, p: float) -> float:
if k <= 0:
return 1.0
if k > n:
return 0.0
var s := 0.0
for i in range(k, n + 1):
s += _pmf(n, i, p)
return clampf(s, 0.0, 1.0)
static func _own(values: Array, face: int) -> int:
var c := 0
for v in values:
if v == face:
c += 1
return c
## Wahrscheinlichkeit, dass ein Gebot (count, face) genau stimmt.
static func exact_chance(values: Array, unknown: int, count: int, face: int) -> float:
var k := count - _own(values, face)
if k < 0 or k > unknown:
return 0.0
return _pmf(unknown, k, 1.0 / 6.0)
## Wahrscheinlichkeit, dass ein Gebot (count, face) hält.
static func chance(values: Array, unknown: int, count: int, face: int) -> float:
return tail(unknown, count - _own(values, face), 1.0 / 6.0)
## 0 = leicht, 1 = normal, 2 = schwer. Das Menü setzt es vor dem Start.
static var stufe := 1
## Je Stufe vier Regler:
## bluff - wie oft er blufft, statt ein glaubwürdiges Gebot anzufechten.
## Ohne das wäre er ein reiner Rechner, und den liest man.
## genau - ab welcher Trefferchance er "Genau" ruft.
## zweifel - unter welcher Haltechance er anficht. Hoch heißt: voreilig.
## sicher - welche Chance ein eigenes Gebot mindestens haben muss.
const STUFEN := [
{"bluff": 0.15, "genau": 0.35, "zweifel": 0.40, "sicher": 0.30},
{"bluff": 0.40, "genau": 0.22, "zweifel": 0.30, "sicher": 0.42},
# Gemessen: bei zweifel 0.24 / genau 0.18 dauerte eine Partie doppelt so
# lang wie auf normal - viele Züge je Runde, viele nullsummige Genau-Treffer.
{"bluff": 0.50, "genau": 0.26, "zweifel": 0.28, "sicher": 0.48},
]
## Wie oft er ein sicheres Gebot einen Schritt höher setzt, als nötig wäre.
const FRECH := 0.12
## Wie oft er eine gute Genau-Gelegenheit auch wirklich nutzt.
const GENAU_LUST := 0.35
## Ein Kapitän der Kampagne bringt sein eigenes Temperament mit: dieselben
## vier Regler, nur andere Werte. Leer heißt: die Stufe gilt.
static var temperament: Dictionary = {}
static func _regler() -> Dictionary:
if not temperament.is_empty():
return temperament
return STUFEN[clampi(stufe, 0, STUFEN.size() - 1)]
## `wissen`: was der Bot über die anderen weiss - "bekannt" sind aufgedeckte
## Gegnerwürfel (Spähen), "fenster" ist je Augenzahl [von, bis] vom Zählen.
## Ohne das würfe er die Münzen und ignorierte, was sie ihm sagen.
static func choose_action(values: Array, total_dice: int, bid: Dictionary,
must_bid: bool, rng: RandomNumberGenerator, wissen: Dictionary = {}) -> Dictionary:
var known: Array = []
for v in values:
if v > 0:
known.append(v)
for v in wissen.get("bekannt", []):
known.append(int(v))
var unknown := maxi(0, total_dice - known.size())
var fenster: Dictionary = wissen.get("fenster", {})
if bid.is_empty():
var op := _opening(known, unknown, rng)
return {"type": "bid", "count": op.count, "face": op.face}
var r := _regler()
var hold := chance(known, unknown, bid.count, bid.face)
var genau_p := exact_chance(known, unknown, bid.count, bid.face)
if fenster.has(int(bid.face)):
# Gezählt: das Fenster schlägt jede Schätzung.
var f: Array = fenster[int(bid.face)]
var von := int(f[0])
var bis := int(f[1])
if bis < int(bid.count):
hold = 0.0
genau_p = 0.0
elif von >= int(bid.count):
hold = 1.0
genau_p = 1.0 if von == bis and von == int(bid.count) else (0.34 if von == int(bid.count) else 0.0)
else:
hold = float(bis - int(bid.count) + 1) / float(bis - von + 1)
genau_p = 1.0 / float(bis - von + 1)
var raise_ = _best_raise(known, unknown, total_dice, bid, float(r.sicher))
var low := lowest_raise(bid, total_dice)
if not must_bid:
# Liegt die Ansage vermutlich haarscharf, lohnt "Genau" mehr als die
# Anfechtung: der Ansager verliert, und man bekommt selbst einen dazu.
# Aber nur ab und zu: Genau nimmt keinen Würfel vom Tisch. Ein Bot,
# der es bei jeder Gelegenheit ruft, zieht die Partie ins Endlose.
# Sicheres Wissen (Fenster exakt) nutzt er dagegen immer.
if genau_p >= 0.999 or (genau_p > float(r.genau) and hold < 0.6 and rng.randf() < GENAU_LUST):
return {"type": "exact"}
var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07)
if hold < doubt and (raise_.is_empty() or raise_.p < 0.32):
return {"type": "challenge"}
if raise_.is_empty():
# Kein sicheres Gebot mehr. Anfechten wäre ehrlich - aber wer immer
# ehrlich ist, wird gelesen. Also ab und zu bluffen, solange es noch
# ein gültiges Gebot gibt.
if not low.is_empty() and (must_bid or rng.randf() < float(r.bluff)):
return {"type": "bid", "count": low.count, "face": low.face}
return {"type": "challenge"}
if rng.randf() < FRECH and int(raise_.count) + 1 <= total_dice:
return {"type": "bid", "count": int(raise_.count) + 1, "face": raise_.face}
return {"type": "bid", "count": raise_.count, "face": raise_.face}
## Das kleinste Gebot, das noch über dem liegenden steht ({} = keins mehr).
static func lowest_raise(bid: Dictionary, total: int) -> Dictionary:
if int(bid.face) < 6:
return {"count": int(bid.count), "face": int(bid.face) + 1}
if int(bid.count) + 1 <= total:
return {"count": int(bid.count) + 1, "face": 1}
return {}
static func _opening(known: Array, unknown: int, rng: RandomNumberGenerator) -> Dictionary:
var best_face := rng.randi_range(1, 6)
var best := -1
for f in range(1, 7):
var c := _own(known, f)
if c > best:
best = c
best_face = f
var count := maxi(1, best + int(round(unknown / 6.0)))
return {"count": count, "face": best_face}
static func _best_raise(known: Array, unknown: int, total: int, bid: Dictionary,
sicher: float) -> Dictionary:
var best := {}
for face in range(1, 7):
for count in range(1, total + 1):
if not _is_higher(count, face, bid):
continue
var p := chance(known, unknown, count, face)
if p < sicher:
continue
if best.is_empty() or count < best.count or (count == best.count and p > best.p):
best = {"count": count, "face": face, "p": p}
break
return best
static func _is_higher(count: int, face: int, bid: Dictionary) -> bool:
if count > bid.count:
return true
return count == bid.count and face > bid.face
## Welcher eigene Würfel soll umgedreht werden? Einer, der die Augenzahl
## noch nicht zeigt - sonst wäre die Münze verschenkt.
static func target_for(values: Array, face: int) -> int:
for i in values.size():
if values[i] != face:
return i
return 0