This commit is contained in:
team3
2026-09-16 15:04:02 +02:00
parent 3be22351a3
commit b9d6b115c0
23 changed files with 795 additions and 425 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: dev server prod logs down test .PHONY: dev server prod logs down test sim
GODOT ?= $(HOME)/bin/godot GODOT ?= $(HOME)/bin/godot
@@ -16,6 +16,10 @@ dev:
test: test:
GODOT=$(GODOT) bash tests/run.sh GODOT=$(GODOT) bash tests/run.sh
# Bot gegen Bot, je Stufe 12 Partien parallel (~3 min): Rundenzahl und Siegquoten.
sim:
@for s in 0 1 2; do $(GODOT) --headless --path . res://tests/sim.tscn -- $$s 12 2>/dev/null | grep "^SIM" & done; wait
# ── Produktion ─────────────────────────────────────────────────────────────── # ── Produktion ───────────────────────────────────────────────────────────────
# Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst. # Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst.
prod: prod:

View File

@@ -41,7 +41,8 @@ Die Partie läuft nicht beim Host, sondern auf `dice.marha.de`:
* **Wiedereinstieg.** Wer die Verbindung verliert, wird zum Bot. Tritt er mit * **Wiedereinstieg.** Wer die Verbindung verliert, wird zum Bot. Tritt er mit
demselben Namen und Code wieder bei, bekommt er seinen Platz zurück: der demselben Namen und Code wieder bei, bekommt er seinen Platz zurück: der
Server schickt ihm die laufende Aufstellung statt einer Lobby, Zustand und Server schickt ihm die laufende Aufstellung statt einer Lobby, Zustand und
Würfellage kommen kurz danach nach. Würfellage kommen kurz danach nach. Das Menü merkt sich den Abbruch und
bietet **Zurück in Raum XY** an - ein Klick, kein Code tippen.
* Der **Host** ist der erste Spieler im Raum. Geht er, rückt der nächste nach. * Der **Host** ist der erste Spieler im Raum. Geht er, rückt der nächste nach.
Nur er stellt die Bots ein und startet. Nur er stellt die Bots ein und startet.
* Der letzte macht das Licht aus: verlässt der letzte Spieler den Raum, wird er * Der letzte macht das Licht aus: verlässt der letzte Spieler den Raum, wird er
@@ -162,8 +163,12 @@ Aufgedeckt wird in drei Etappen: Becher hoch, dann zählen, dann das Urteil.
Beim Zählen leuchten die passenden Würfel nacheinander auf. So baut sich die Beim Zählen leuchten die passenden Würfel nacheinander auf. So baut sich die
Spannung auf, statt dass alles auf einmal dasteht. Spannung auf, statt dass alles auf einmal dasteht.
Nach dem Ende gibt es solo ein **Nochmal** mit denselben Bots. Im Netz führt Nach dem Ende gibt es ein **Nochmal** mit derselben Runde - solo sofort, im
der Weg zurück ins Menü. Netz für den Raumbesitzer; der Server setzt den Raum dann für alle neu auf.
Fällt ein Würfel je über die Bande oder durch den Filz, fängt ihn ein
Fangnetz und legt ihn zurück in seine Zone - sonst käme das Würfeln nie zur
Ruhe.
Wer keine Würfel mehr hat, scheidet aus und schaut zu. Der Letzte gewinnt. Wer keine Würfel mehr hat, scheidet aus und schaut zu. Der Letzte gewinnt.
@@ -237,7 +242,7 @@ das ist seine Grundwirkung, und Wappen macht daraus nur eine 6.
die Münzen zu seiner Zone. die Münzen zu seiner Zone.
* Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es gibt * Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es gibt
keine Tondateien, so wie es auch keine Bilddateien gibt. keine Tondateien, so wie es auch keine Bilddateien gibt.
* Die Regeln stehen im Menü unter **Regeln**. * Die Regeln stehen im Menü unter **Regeln**, inklusive aller sechs Münzen.
### Bots ### Bots
@@ -258,12 +263,14 @@ vermutlich haarscharf, rufen sie „Genau".
| `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart | | `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart |
| `scripts/boosters.gd` | Autoload: Münzkatalog mit Grund- und Wappenwirkung | | `scripts/boosters.gd` | Autoload: Münzkatalog mit Grund- und Wappenwirkung |
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich | | `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
| `scripts/muenzen.gd` | Münzen: ziehen, werfen, Wirkung, Bot-Absicht, Topf |
| `scripts/aufdecken.gd` | Aufdecken in Etappen, Genau, Topfvergabe |
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera | | `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |
| `scripts/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage | | `scripts/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage |
| `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) | | `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) |
| `scripts/hud.gd` | Spiel-UI | | `scripts/hud.gd` | Spiel-UI |
| `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien | | `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien |
| `tests/` | Headless-Tests, `make test` - siehe `tests/README.md` | | `tests/` | Headless-Tests, `make test` (~30 s); `make sim` misst die Bots - siehe `tests/README.md` |
| `scripts/menu.gd` | Startmenü und Lobby | | `scripts/menu.gd` | Startmenü und Lobby |
Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten

View File

@@ -55,10 +55,14 @@ static var stufe := 1
const STUFEN := [ const STUFEN := [
{"bluff": 0.15, "genau": 0.35, "zweifel": 0.40, "sicher": 0.30}, {"bluff": 0.15, "genau": 0.35, "zweifel": 0.40, "sicher": 0.30},
{"bluff": 0.40, "genau": 0.22, "zweifel": 0.30, "sicher": 0.42}, {"bluff": 0.40, "genau": 0.22, "zweifel": 0.30, "sicher": 0.42},
{"bluff": 0.50, "genau": 0.18, "zweifel": 0.24, "sicher": 0.50}, # 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. ## Wie oft er ein sicheres Gebot einen Schritt höher setzt, als nötig wäre.
const FRECH := 0.12 const FRECH := 0.12
## Wie oft er eine gute Genau-Gelegenheit auch wirklich nutzt.
const GENAU_LUST := 0.35
static func _regler() -> Dictionary: static func _regler() -> Dictionary:
@@ -85,7 +89,10 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary,
if not must_bid: if not must_bid:
# Liegt die Ansage vermutlich haarscharf, lohnt "Genau" mehr als die # Liegt die Ansage vermutlich haarscharf, lohnt "Genau" mehr als die
# Anfechtung: der Ansager verliert, und man bekommt selbst einen dazu. # Anfechtung: der Ansager verliert, und man bekommt selbst einen dazu.
if exact_chance(known, unknown, bid.count, bid.face) > float(r.genau) and hold < 0.6: # 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.
if exact_chance(known, unknown, bid.count, bid.face) > float(r.genau) and hold < 0.6 \
and rng.randf() < GENAU_LUST:
return {"type": "exact"} return {"type": "exact"}
var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07) var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07)
if hold < doubt and (raise_.is_empty() or raise_.p < 0.32): if hold < doubt and (raise_.is_empty() or raise_.p < 0.32):

104
scripts/aufdecken.gd Normal file
View File

@@ -0,0 +1,104 @@
extends RefCounted
## Das Aufdecken in drei Etappen: Becher hoch, zählen, Urteil. Dazu Genau
## und die Vergabe des Topfs. Arbeitet auf dem Spiel `g`.
var g: Node
func _init(spiel: Node) -> void:
g = spiel
func _phase_reveal(result: Dictionary) -> void:
g.phase = "reveal"
g.deadline = 0.0
var b: Dictionary = result.bid
var rufer: int = result.challenger
var genau: bool = result.get("exact", false)
var total: int = g._count_face(int(b.face))
# 1. Becher hoch. Noch kein Urteil - erst schauen.
g.reveal_total = -1
g.banner = "Genau?" if genau else "Aufdecken"
for i in g._alive_seats():
g.table.arrange_and_show(i, g.host_values[i], g.rng)
g.table.set_cup_down(i, false)
# Gezielt an die Mitspieler am Tisch: ein Peer, der nur in der Lobby sitzt,
# hat keinen Spielknoten und würde das Paket ins Leere laufen lassen.
var values := _all_values()
for i in g.seats.size():
var peer: int = g.seats[i].peer
if g._peer_live(peer):
g._reveal.rpc_id(peer, values, g.banner)
g._push_state()
await g._sleep(g.ZEIT_BECHER)
if not g._alive():
return
# 2. Zählen. Die Ansage in der Mitte färbt sich jetzt ein.
g.reveal_total = total
g.banner = "%d gefunden, %d angesagt" % [total, b.count]
g._push_state()
await g._sleep(g.ZEIT_ZAEHLEN)
if not g._alive():
return
# 3. Urteil. Bei "Genau" trifft es alle anderen - oder nur den Rufer.
var verlierer: Array = []
var winner: int
var bonus := -1
if genau and total == int(b.count):
# Fester Schwung von zwei, egal wie viele am Tisch sitzen: der Ansager
# verliert einen, der Rufer bekommt einen. Mehr als eine Anfechtung
# bringt, weniger als "alle anderen" - das kippte bei vielen Spielern.
verlierer = [int(b.seat)]
winner = rufer
bonus = rufer
g.banner = "Genau! %s verliert, %s bekommt einen Würfel" % [
g.seats[int(b.seat)].name, g.seats[rufer].name]
elif genau:
verlierer = [rufer]
winner = int(b.seat)
g.banner = "Daneben - %s verliert" % g.seats[rufer].name
else:
var loser := rufer if total >= int(b.count) else int(b.seat)
verlierer = [loser]
winner = int(b.seat) if loser == rufer else rufer
g.banner = "%s verliert" % g.seats[loser].name
g._log("%d x %d angesagt - tatsächlich %d. %s" % [b.count, b.face, total, g.banner])
g._topf_an(winner)
g._push_state()
await g._sleep(g.ZEIT_URTEIL)
if not g._alive():
return
for l: int in verlierer:
g.seats[l].count -= 1
if g.seats[l].count <= 0:
g.seats[l].alive = false
g.seats[l].count = 0
g.host_values[l] = []
g.table.set_dice_count(l, 0)
g._log("%s scheidet aus." % g.seats[l].name)
else:
g._zieh_muenze(l)
if bonus >= 0 and g.seats[bonus].alive:
g.seats[bonus].count += 1
g.host_values[bonus].append(g.rng.randi_range(1, 6))
g.table.set_dice_count(bonus, g.seats[bonus].count)
# Nach einem Treffer beginnt der Rufer, sonst der Verlierer.
var naechster: int = winner if genau and total == int(b.count) else verlierer[0]
g.starter = naechster if g.seats[naechster].alive else g._next_alive(naechster)
# Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone
# sähe er aus, als lägen dort noch Würfel.
for i in g.seats.size():
g.table.set_dice_visible(i, false)
g.table.set_cup_down(i, g.seats[i].alive)
g.banner = ""
g._push_state()
func _all_values() -> Array:
var out: Array = []
for i in g.seats.size():
out.append(g.host_values[i] if g.seats[i].alive else [])
return out

View File

@@ -13,6 +13,11 @@ const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist,
var table: GameTable var table: GameTable
var hud: CanvasLayer var hud: CanvasLayer
## Alle Wartezeiten des Ablaufs (Bot-Pausen, Aufdecken, Münzwurf) laufen
## durch `_sleep` und lassen sich hiermit stauchen. Im Spiel 1.0; die Tests
## setzen 0, sonst dauert eine Bot-Partie Minuten statt Sekunden. Kein
## class_name dafür: den Klassencache baut nur der Editor, headless fehlt er.
var tempo := 1.0
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}] var seats: Array = [] ## [{peer, name, ai, count, alive}]
@@ -64,13 +69,14 @@ func setup(cfg: Dictionary) -> void:
seats.append({"peer": int(s.peer), "name": String(s.name), "ai": bool(s.ai), seats.append({"peer": int(s.peer), "name": String(s.name), "ai": bool(s.ai),
"count": int(s.count), "alive": bool(s.alive), "verwaist": false}) "count": int(s.count), "alive": bool(s.alive), "verwaist": false})
else: else:
var dice := int(cfg.get("dice", START_DICE)) # Tests spielen mit weniger
var peers: Array = cfg.peers var peers: Array = cfg.peers
for p in peers: for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false, seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
"count": START_DICE, "alive": true, "verwaist": false}) "count": dice, "alive": true, "verwaist": false})
for i in int(cfg.ai): for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true, seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
"count": START_DICE, "alive": true, "verwaist": false}) "count": dice, "alive": true, "verwaist": false})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz. # Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
var me := multiplayer.get_unique_id() var me := multiplayer.get_unique_id()
@@ -299,6 +305,10 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
continue continue
var ok := true var ok := true
for s in list: for s in list:
# Verirrte Würfel zurückholen, sonst wartet man hier vergebens.
if table.fange_verirrte(s):
ok = false
continue
for d in table.dice_of(s): for d in table.dice_of(s):
if d.freeze: if d.freeze:
continue continue
@@ -311,136 +321,45 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
return return
# ---------------------------------------------------------------- Münzen # ---------------------------------------------------------------- Münzen und Aufdecken
# Ausgelagert nach muenzen.gd und aufdecken.gd. Die Hüllen hier bleiben, weil
# Tests und Aufrufer den Weg über das Spiel kennen.
var muenzen := preload("res://scripts/muenzen.gd").new(self)
var aufdecken := preload("res://scripts/aufdecken.gd").new(self)
## Wer einen Würfel abgibt, bekommt eine Münze. Das ist der einzige Nachschub.
func _zieh_muenze(seat: int) -> void: func _zieh_muenze(seat: int) -> void:
var muenzen: Array = hand.get(seat, []) muenzen._zieh_muenze(seat)
if muenzen.size() >= BoosterDB.HANDLIMIT:
_log("%s hat schon drei Münzen und bekommt keine." % seats[seat].name)
return
var id := BoosterDB.zieh(rng)
muenzen.append(id)
hand[seat] = muenzen
_log("%s bekommt %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
## Ausspielen heißt: Münze in den Topf werfen. Geht nur am eigenen Zug, bevor
## man ansagt - so ist der Zug eine Entscheidung und nichts kommt sich in die
## Quere. Wappen verstärkt, Zahl lässt es bei der Grundwirkung.
func _do_play(seat: int, id: String, target: int) -> void: func _do_play(seat: int, id: String, target: int) -> void:
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat): await muenzen._do_play(seat, id, target)
return
if _muenze_laeuft:
return
if BoosterDB.needs_bid(id) and bid.is_empty():
return
var muenzen: Array = hand.get(seat, [])
if not muenzen.has(id):
return
muenzen.erase(id)
hand[seat] = muenzen
_muenze_laeuft = true
var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE
_wurf_nr += 1
topf.append({"seat": seat, "id": id, "stark": stark, "nr": _wurf_nr})
while topf.size() > BoosterDB.TOPF_MAX:
var weg: Dictionary = topf.pop_front()
_log("%s fällt vom Tisch - der Topf ist voll." % BoosterDB.get_booster(String(weg.id)).name)
_log("%s wirft %s - %s." % [seats[seat].name,
BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"])
# Erst die Münze zeigen, dann wirken lassen. Andersherum sähe man die
# Folge vor der Ursache.
_push_state()
await _sleep(WURF_ZEIT)
if _alive():
await _wirkung(seat, id, target, stark)
_muenze_laeuft = false
if not _alive():
return
_snapshot_all()
_push_state()
func _wirkung(seat: int, id: String, target: int, stark: bool) -> void: func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
var vals: Array = host_values[seat] await muenzen._wirkung(seat, id, target, stark)
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
match id:
"peek":
var info := {}
for o in _alive_seats():
if o != seat:
info[o] = _zufalls_paare(o, 2 if stark else 1)
peeked[seat] = info
"count":
# Zahl: ein Fenster von drei, in dem die Wahrheit irgendwo liegt.
# Genau wüsste man sonst, ob die Ansage hält - kein Bluff mehr.
var n := _count_face(int(bid.face))
var von: int = n if stark else maxi(0, n - rng.randi_range(0, 2))
gezaehlt[seat] = {int(bid.face): [von, von if stark else von + 2]}
"set_face":
for k: int in _ziel_und_zufall(seat, ziel, stark):
_set_die(seat, k, int(bid.face))
"gain_die":
seats[seat].count += 1
table.set_dice_count(seat, seats[seat].count)
host_values[seat].append(1)
var neu: int = host_values[seat].size() - 1
await _neu_werfen(seat, [neu])
if stark:
_set_die(seat, neu, 6)
"bid_up":
bid.count = int(bid.count) + (2 if stark else 1)
last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face}
verlauf.append({"seat": int(bid.seat), "count": bid.count, "face": bid.face, "erhoeht": true})
"gag":
if stark:
for o in _alive_seats():
if o != seat:
must_bid[o] = true
else:
must_bid[_next_alive(seat)] = true
func _count_face(face: int) -> int: func _count_face(face: int) -> int:
var n := 0 return muenzen._count_face(face)
for i in _alive_seats():
for v in host_values[i]:
if v == face:
n += 1
return n
## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen. func _bot_muenze(seat: int, a: Dictionary) -> bool:
func _zufalls_idx(seat: int, n: int) -> Array: return await muenzen._bot_muenze(seat, a)
var frei: Array = range(host_values[seat].size())
var out: Array = []
while out.size() < n and not frei.is_empty():
out.append(frei.pop_at(rng.randi_range(0, frei.size() - 1)))
return out
func _zufalls_paare(seat: int, n: int) -> Array: func _topf_an(winner: int) -> void:
var out: Array = [] muenzen._topf_an(winner)
for k: int in _zufalls_idx(seat, n):
out.append([k, host_values[seat][k]])
return out
## Der selbst gewählte Würfel, bei Wappen zusätzlich ein anderer zufälliger. func _phase_reveal(result: Dictionary) -> void:
func _ziel_und_zufall(seat: int, ziel: int, stark: bool) -> Array: await aufdecken._phase_reveal(result)
var out: Array = [ziel]
if not stark:
return out func _all_values() -> Array:
var frei: Array = [] return aufdecken._all_values()
for k in host_values[seat].size():
if k != ziel:
frei.append(k)
if not frei.is_empty():
out.append(frei[rng.randi_range(0, frei.size() - 1)])
return out
## Würfel neu werfen und die Augenzahl danach übernehmen. ## Würfel neu werfen und die Augenzahl danach übernehmen.
@@ -518,61 +437,6 @@ func _phase_bid() -> Dictionary:
return {} return {}
## Ein Bot wirft eine Münze, wenn sie zu seinem Vorhaben passt. `a` ist die
## geplante Handlung. Gibt zurück, ob geworfen wurde.
func _bot_muenze(seat: int, a: Dictionary) -> bool:
var muenzen: Array = hand.get(seat, [])
if muenzen.is_empty():
return false
var vals: Array = host_values[seat]
var unknown: int = _total_dice() - vals.size()
var will_bieten: bool = a.type == "bid"
# Wie glaubwürdig ist das, was der Bot vorhat bzw. was auf dem Tisch liegt?
var eigenes: float = DiceAI.chance(vals, unknown, int(a.count), int(a.face)) if will_bieten else 0.0
var liegt: float = DiceAI.chance(vals, unknown, int(bid.count), int(bid.face)) if not bid.is_empty() else 0.0
var wahl := ""
var ziel := 0
# Hinten liegen: Nachschub ist der einzige Weg zurück.
if muenzen.has("gain_die") and vals.size() <= _kleinste_hand():
wahl = "gain_die"
# Vor dem Anfechten lieber noch nachsehen, wenn es knapp ist.
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("count"):
wahl = "count"
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("peek"):
wahl = "peek"
# Zudrücken: die Ansage des Gegners höher schrauben, dann anfechten.
elif not will_bieten and muenzen.has("bid_up"):
wahl = "bid_up"
# Ein Bluff steht an: dem Nächsten den Mund verbieten.
elif will_bieten and eigenes < 0.35 and muenzen.has("gag"):
wahl = "gag"
# Auf derselben Augenzahl erhöhen: einen Würfel dazu drehen.
elif will_bieten and not bid.is_empty() and int(a.face) == int(bid.face) \
and muenzen.has("set_face") and vals.count(int(bid.face)) < vals.size():
wahl = "set_face"
ziel = DiceAI.target_for(vals, int(bid.face))
# Voller Vorrat: irgendwas Spielbares raus, sonst wird nie geworfen.
elif muenzen.size() >= BoosterDB.HANDLIMIT:
for id: String in muenzen:
if not BoosterDB.needs_bid(id) or not bid.is_empty():
wahl = id
break
if wahl == "":
return false
await _do_play(seat, wahl, ziel)
await _sleep(0.6)
return true
## Die kleinste Würfelzahl am Tisch - wer da liegt, ist hinten.
func _kleinste_hand() -> int:
var k := 99
for i in _alive_seats():
k = mini(k, seats[i].count)
return k
## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis. ## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis.
func _apply_action(seat: int, a: Dictionary) -> Dictionary: func _apply_action(seat: int, a: Dictionary) -> Dictionary:
if a.type == "exact": if a.type == "exact":
@@ -616,124 +480,11 @@ func _valid_bid(seat: int, count: int, face: int) -> bool:
return count == bid.count and face > bid.face return count == bid.count and face > bid.face
func _phase_reveal(result: Dictionary) -> void:
phase = "reveal"
deadline = 0.0
var b: Dictionary = result.bid
var rufer: int = result.challenger
var genau: bool = result.get("exact", false)
var total := _count_face(int(b.face))
# 1. Becher hoch. Noch kein Urteil - erst schauen.
reveal_total = -1
banner = "Genau?" if genau else "Aufdecken"
for i in _alive_seats():
table.arrange_and_show(i, host_values[i], rng)
table.set_cup_down(i, false)
# Gezielt an die Mitspieler am Tisch: ein Peer, der nur in der Lobby sitzt,
# hat keinen Spielknoten und würde das Paket ins Leere laufen lassen.
var values := _all_values()
for i in seats.size():
var peer: int = seats[i].peer
if _peer_live(peer):
_reveal.rpc_id(peer, values, banner)
_push_state()
await _sleep(ZEIT_BECHER)
if not _alive():
return
# 2. Zählen. Die Ansage in der Mitte färbt sich jetzt ein.
reveal_total = total
banner = "%d gefunden, %d angesagt" % [total, b.count]
_push_state()
await _sleep(ZEIT_ZAEHLEN)
if not _alive():
return
# 3. Urteil. Bei "Genau" trifft es alle anderen - oder nur den Rufer.
var verlierer: Array = []
var winner: int
var bonus := -1
if genau and total == int(b.count):
# Fester Schwung von zwei, egal wie viele am Tisch sitzen: der Ansager
# verliert einen, der Rufer bekommt einen. Mehr als eine Anfechtung
# bringt, weniger als "alle anderen" - das kippte bei vielen Spielern.
verlierer = [int(b.seat)]
winner = rufer
bonus = rufer
banner = "Genau! %s verliert, %s bekommt einen Würfel" % [
seats[int(b.seat)].name, seats[rufer].name]
elif genau:
verlierer = [rufer]
winner = int(b.seat)
banner = "Daneben - %s verliert" % seats[rufer].name
else:
var loser := rufer if total >= int(b.count) else int(b.seat)
verlierer = [loser]
winner = int(b.seat) if loser == rufer else rufer
banner = "%s verliert" % seats[loser].name
_log("%d x %d angesagt - tatsächlich %d. %s" % [b.count, b.face, total, banner])
_topf_an(winner)
_push_state()
await _sleep(ZEIT_URTEIL)
if not _alive():
return
for l: int in verlierer:
seats[l].count -= 1
if seats[l].count <= 0:
seats[l].alive = false
seats[l].count = 0
host_values[l] = []
table.set_dice_count(l, 0)
_log("%s scheidet aus." % seats[l].name)
else:
_zieh_muenze(l)
if bonus >= 0 and seats[bonus].alive:
seats[bonus].count += 1
host_values[bonus].append(rng.randi_range(1, 6))
table.set_dice_count(bonus, seats[bonus].count)
# Nach einem Treffer beginnt der Rufer, sonst der Verlierer.
var naechster: int = winner if genau and total == int(b.count) else verlierer[0]
starter = naechster if seats[naechster].alive else _next_alive(naechster)
# Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone
# sähe er aus, als lägen dort noch Würfel.
for i in seats.size():
table.set_dice_visible(i, false)
table.set_cup_down(i, seats[i].alive)
banner = ""
_push_state()
## Wer das Aufdecken gewinnt, nimmt aus dem Topf, so viel in den Vorrat
## passt. Der Rest bleibt liegen und wächst in die nächste Runde - so ist
## ein voller Vorrat kein Grund, den Topf zu verschenken.
func _topf_an(winner: int) -> void:
if topf.is_empty():
return
var h: Array = hand.get(winner, [])
var genommen := 0
_topf_ziel = winner
while not topf.is_empty() and h.size() < BoosterDB.HANDLIMIT:
h.append(String(topf.pop_front().id))
genommen += 1
hand[winner] = h
_log("%s nimmt %d Münze(n) aus dem Topf, %d bleiben." % [
seats[winner].name, genommen, topf.size()])
func _all_values() -> Array:
var out: Array = []
for i in seats.size():
out.append(host_values[i] if seats[i].alive else [])
return out
## process_always = false: eine offene Pause hält den Timer wirklich an. ## process_always = false: eine offene Pause hält den Timer wirklich an.
func _sleep(sec: float) -> void: func _sleep(sec: float) -> void:
if not _alive(): if not _alive():
return return
await get_tree().create_timer(sec, false).timeout await get_tree().create_timer(sec * tempo, false).timeout
func _wait_until(cond: Callable, timeout: float) -> bool: func _wait_until(cond: Callable, timeout: float) -> bool:
@@ -983,11 +734,15 @@ func _on_pause_changed(on: bool) -> void:
_push_state() _push_state()
## Noch eine Solopartie mit denselben Bots. Der Wunsch wandert über Net ins ## Noch eine Partie mit denselben Bots. Solo wandert der Wunsch über Net ins
## neu geladene Menü, das dann sofort startet. ## neu geladene Menü, das dann sofort startet. Im Netz bittet der Raumbesitzer
## den Server, den Raum neu aufzusetzen.
func _on_rematch() -> void: func _on_rematch() -> void:
if _quitting: if _quitting:
return return
if Net.room_code != Net.SOLO:
Net.request_rematch()
return
var bots := 0 var bots := 0
for s in seats: for s in seats:
if s.ai: if s.ai:

View File

@@ -748,9 +748,9 @@ func update_state(st: Dictionary, my_seat: int) -> void:
banner_label.text = "" if st.phase == "over" else st.banner banner_label.text = "" if st.phase == "over" else st.banner
if st.phase == "over" and not over_slot.visible: if st.phase == "over" and not over_slot.visible:
over_title.text = String(st.banner) over_title.text = String(st.banner)
# Nochmal gibt es nur solo. Im Netz müsste der Server den Raum neu # Nochmal: solo immer, im Netz nur für den Raumbesitzer - der Server
# aufsetzen; bis dahin geht es zurück ins Menü. # setzt den Raum dann für alle neu auf.
over_again.visible = Net.room_code == Net.SOLO over_again.visible = Net.room_code == Net.SOLO or Net.is_owner
over_slot.visible = true over_slot.visible = true
var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive

View File

@@ -49,7 +49,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
func _raum_bauen(code: String, cfg: Dictionary) -> void: func _raum_bauen(code: String, cfg: Dictionary) -> void:
if raeume.has_node(code): if raeume.has_node(code):
# Ein Nochmal ersetzt die alte Partie; alles andere ist ein Doppel.
if not cfg.get("rematch", false):
return return
_raum_abbauen(code)
if menu != null: if menu != null:
menu.queue_free() menu.queue_free()
menu = null menu = null
@@ -103,9 +106,12 @@ func _wiedereinstieg(code: String, peer: int, pname: String) -> void:
Net.rejoin_annehmen(code, peer, pname, cfg) Net.rejoin_annehmen(code, peer, pname, cfg)
## Sofort aus dem Baum, nicht erst am Frameende: ein Nochmal baut den Raum
## unter demselben Namen gleich wieder auf.
func _raum_abbauen(code: String) -> void: func _raum_abbauen(code: String) -> void:
var raum := raeume.get_node_or_null(NodePath(code)) var raum := raeume.get_node_or_null(NodePath(code))
if raum != null: if raum != null:
raeume.remove_child(raum)
raum.queue_free() raum.queue_free()

View File

@@ -10,6 +10,7 @@ var current := "main"
# Einzelspieler # Einzelspieler
var solo_bots: NumberRow var solo_bots: NumberRow
var zurueck_btn: Button ## "Zurück in Raum XY" nach einem Abbruch
var solo_stufe: OptionButton var solo_stufe: OptionButton
var lobby_stufe: OptionButton var lobby_stufe: OptionButton
@@ -62,6 +63,10 @@ func _show(which: String) -> void:
for key: String in screens: for key: String in screens:
screens[key].visible = key == which screens[key].visible = key == which
_sagen("") _sagen("")
if which == "main" and zurueck_btn != null:
zurueck_btn.visible = not Net.letzter_raum.is_empty()
if zurueck_btn.visible:
zurueck_btn.text = "Zurück in Raum %s" % Net.letzter_raum.code
if which == "lobby": if which == "lobby":
_refresh() _refresh()
@@ -88,6 +93,12 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label:
return l return l
func _on_wieder_rein() -> void:
_show("join")
_sagen("Verbinde mit Raum %s …" % Net.letzter_raum.get("code", ""))
Net.wieder_rein()
## Leicht, normal, schwer - stellt Bluff-Quote, Genau-Schwelle und Vorsicht ## Leicht, normal, schwer - stellt Bluff-Quote, Genau-Schwelle und Vorsicht
## der Bots ein. ## der Bots ein.
func _stufen_wahl() -> OptionButton: func _stufen_wahl() -> OptionButton:
@@ -170,6 +181,9 @@ func _title(box: VBoxContainer) -> void:
func _build_main() -> Control: func _build_main() -> Control:
var box := _box(360) var box := _box(360)
_title(box) _title(box)
# Nach einem Verbindungsabbruch: derselbe Name, derselbe Code, ein Klick.
zurueck_btn = _button("", box, _on_wieder_rein)
zurueck_btn.visible = false
_button("Einzelspieler", box, _show.bind("single")) _button("Einzelspieler", box, _show.bind("single"))
_button("Mehrspieler", box, _show.bind("multi")) _button("Mehrspieler", box, _show.bind("multi"))
_button("Regeln", box, _show.bind("regeln")) _button("Regeln", box, _show.bind("regeln"))
@@ -191,6 +205,8 @@ func _build_regeln() -> Control:
["Topf", "Geworfene Münzen liegen in der Mitte. Wer das Aufdecken gewinnt, nimmt sie. Mehr als vier passen nicht in den Vorrat; der Rest bleibt liegen. Im Topf liegen höchstens sechs - die älteste fällt vom Tisch."], ["Topf", "Geworfene Münzen liegen in der Mitte. Wer das Aufdecken gewinnt, nimmt sie. Mehr als vier passen nicht in den Vorrat; der Rest bleibt liegen. Im Topf liegen höchstens sechs - die älteste fällt vom Tisch."],
["Ende", "Wer keine Würfel mehr hat, schaut zu. Der Letzte gewinnt."], ["Ende", "Wer keine Würfel mehr hat, schaut zu. Der Letzte gewinnt."],
] ]
for b in BoosterDB.ALL:
absaetze.append([String(b.name), "%s Wappen: %s" % [b.desc, b.stark]])
for a: Array in absaetze: for a: Array in absaetze:
var h := HBoxContainer.new() var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 12) h.add_theme_constant_override("separation", 12)
@@ -206,7 +222,13 @@ func _build_regeln() -> Control:
h.add_child(t) h.add_child(t)
box.add_child(h) box.add_child(h)
_button("Zurück", box, _show.bind("main")) _button("Zurück", box, _show.bind("main"))
return box # Mit den Münzen passt die Seite nicht mehr auf 720 Zeilen: scrollen.
var sc := ScrollContainer.new()
sc.custom_minimum_size = Vector2(720, 560)
sc.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sc.add_child(box)
return sc
func _build_single() -> Control: func _build_single() -> Control:

212
scripts/muenzen.gd Normal file
View File

@@ -0,0 +1,212 @@
extends RefCounted
## Münzen: ziehen, werfen, Wirkung, Bot-Absicht, Topf. Arbeitet auf dem Spiel
## `g` - Zustand und Ablauf bleiben dort, hier steht nur, was Münzen tun.
## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er.
var g: Node
func _init(spiel: Node) -> void:
g = spiel
## Wer einen Würfel abgibt, bekommt eine Münze. Das ist der einzige Nachschub.
func _zieh_muenze(seat: int) -> void:
var muenzen: Array = g.hand.get(seat, [])
if muenzen.size() >= BoosterDB.HANDLIMIT:
g._log("%s hat schon drei Münzen und bekommt keine." % g.seats[seat].name)
return
var id: String = BoosterDB.zieh(g.rng)
muenzen.append(id)
g.hand[seat] = muenzen
g._log("%s bekommt %s." % [g.seats[seat].name, BoosterDB.get_booster(id).name])
## Ausspielen heißt: Münze in den Topf werfen. Geht nur am eigenen Zug, bevor
## man ansagt - so ist der Zug eine Entscheidung und nichts kommt sich in die
## Quere. Wappen verstärkt, Zahl lässt es bei der Grundwirkung.
func _do_play(seat: int, id: String, target: int) -> void:
if seat < 0 or g.phase != "bid" or seat != g.turn or g._answered.has(seat):
return
if g._muenze_laeuft:
return
if BoosterDB.needs_bid(id) and g.bid.is_empty():
return
var muenzen: Array = g.hand.get(seat, [])
if not muenzen.has(id):
return
muenzen.erase(id)
g.hand[seat] = muenzen
g._muenze_laeuft = true
var stark: bool = g.rng.randf() < BoosterDB.WAPPEN_CHANCE
g._wurf_nr += 1
g.topf.append({"seat": seat, "id": id, "stark": stark, "nr": g._wurf_nr})
while g.topf.size() > BoosterDB.TOPF_MAX:
var weg: Dictionary = g.topf.pop_front()
g._log("%s fällt vom Tisch - der Topf ist voll." % BoosterDB.get_booster(String(weg.id)).name)
g._log("%s wirft %s - %s." % [g.seats[seat].name,
BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"])
# Erst die Münze zeigen, dann wirken lassen. Andersherum sähe man die
# Folge vor der Ursache.
g._push_state()
await g._sleep(g.WURF_ZEIT)
if g._alive():
await _wirkung(seat, id, target, stark)
g._muenze_laeuft = false
if not g._alive():
return
g._snapshot_all()
g._push_state()
func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
var vals: Array = g.host_values[seat]
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
match id:
"peek":
var info := {}
for o in g._alive_seats():
if o != seat:
info[o] = _zufalls_paare(o, 2 if stark else 1)
g.peeked[seat] = info
"count":
# Zahl: ein Fenster von drei, in dem die Wahrheit irgendwo liegt.
# Genau wüsste man sonst, ob die Ansage hält - kein Bluff mehr.
var n: int = _count_face(int(g.bid.face))
var von: int = n if stark else maxi(0, n - g.rng.randi_range(0, 2))
g.gezaehlt[seat] = {int(g.bid.face): [von, von if stark else von + 2]}
"set_face":
for k: int in _ziel_und_zufall(seat, ziel, stark):
g._set_die(seat, k, int(g.bid.face))
"gain_die":
g.seats[seat].count += 1
g.table.set_dice_count(seat, g.seats[seat].count)
g.host_values[seat].append(1)
var neu: int = g.host_values[seat].size() - 1
await g._neu_werfen(seat, [neu])
if stark:
g._set_die(seat, neu, 6)
"bid_up":
g.bid.count = int(g.bid.count) + (2 if stark else 1)
g.last_bids[int(g.bid.seat)] = {"count": g.bid.count, "face": g.bid.face}
g.verlauf.append({"seat": int(g.bid.seat), "count": g.bid.count, "face": g.bid.face, "erhoeht": true})
"gag":
if stark:
for o in g._alive_seats():
if o != seat:
g.must_bid[o] = true
else:
g.must_bid[g._next_alive(seat)] = true
func _count_face(face: int) -> int:
var n := 0
for i in g._alive_seats():
for v in g.host_values[i]:
if v == face:
n += 1
return n
## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen.
func _zufalls_idx(seat: int, n: int) -> Array:
var frei: Array = range(g.host_values[seat].size())
var out: Array = []
while out.size() < n and not frei.is_empty():
out.append(frei.pop_at(g.rng.randi_range(0, frei.size() - 1)))
return out
func _zufalls_paare(seat: int, n: int) -> Array:
var out: Array = []
for k: int in _zufalls_idx(seat, n):
out.append([k, g.host_values[seat][k]])
return out
## Der selbst gewählte Würfel, bei Wappen zusätzlich ein anderer zufälliger.
func _ziel_und_zufall(seat: int, ziel: int, stark: bool) -> Array:
var out: Array = [ziel]
if not stark:
return out
var frei: Array = []
for k in g.host_values[seat].size():
if k != ziel:
frei.append(k)
if not frei.is_empty():
out.append(frei[g.rng.randi_range(0, frei.size() - 1)])
return out
## Ein Bot wirft eine Münze, wenn sie zu seinem Vorhaben passt. `a` ist die
## geplante Handlung. Gibt zurück, ob geworfen wurde.
func _bot_muenze(seat: int, a: Dictionary) -> bool:
var muenzen: Array = g.hand.get(seat, [])
if muenzen.is_empty():
return false
var vals: Array = g.host_values[seat]
var unknown: int = g._total_dice() - vals.size()
var will_bieten: bool = a.type == "bid"
# Wie glaubwürdig ist das, was der Bot vorhat bzw. was auf dem Tisch liegt?
var eigenes: float = DiceAI.chance(vals, unknown, int(a.count), int(a.face)) if will_bieten else 0.0
var liegt: float = DiceAI.chance(vals, unknown, int(g.bid.count), int(g.bid.face)) if not g.bid.is_empty() else 0.0
var wahl := ""
var ziel := 0
# Wirklich hinten liegen: Nachschub ist der Weg zurück. Bei Gleichstand
# nicht - sonst wirft ihn zu Beginn jeder, und der Tisch wird nie leerer.
if muenzen.has("gain_die") and vals.size() < _groesste_hand():
wahl = "gain_die"
# Vor dem Anfechten lieber noch nachsehen, wenn es knapp ist.
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("count"):
wahl = "count"
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("peek"):
wahl = "peek"
# Zudrücken: die Ansage des Gegners höher schrauben, dann anfechten.
elif not will_bieten and muenzen.has("bid_up"):
wahl = "bid_up"
# Ein Bluff steht an: dem Nächsten den Mund verbieten.
elif will_bieten and eigenes < 0.35 and muenzen.has("gag"):
wahl = "gag"
# Auf derselben Augenzahl erhöhen: einen Würfel dazu drehen.
elif will_bieten and not g.bid.is_empty() and int(a.face) == int(g.bid.face) \
and muenzen.has("set_face") and vals.count(int(g.bid.face)) < vals.size():
wahl = "set_face"
ziel = DiceAI.target_for(vals, int(g.bid.face))
# Voller Vorrat: irgendwas Spielbares raus, sonst wird nie geworfen.
elif muenzen.size() >= BoosterDB.HANDLIMIT:
for id: String in muenzen:
if not BoosterDB.needs_bid(id) or not g.bid.is_empty():
wahl = id
break
if wahl == "":
return false
await _do_play(seat, wahl, ziel)
await g._sleep(0.6)
return true
## Die grösste Würfelzahl am Tisch - wer weniger hat, liegt hinten.
func _groesste_hand() -> int:
var k := 0
for i in g._alive_seats():
k = maxi(k, g.seats[i].count)
return k
## Wer das Aufdecken gewinnt, nimmt aus dem Topf, so viel in den Vorrat
## passt. Der Rest bleibt liegen und wächst in die nächste Runde - so ist
## ein voller Vorrat kein Grund, den Topf zu verschenken.
func _topf_an(winner: int) -> void:
if g.topf.is_empty():
return
var h: Array = g.hand.get(winner, [])
var genommen := 0
g._topf_ziel = winner
while not g.topf.is_empty() and h.size() < BoosterDB.HANDLIMIT:
h.append(String(g.topf.pop_front().id))
genommen += 1
g.hand[winner] = h
g._log("%s nimmt %d Münze(n) aus dem Topf, %d bleiben." % [
g.seats[winner].name, genommen, g.topf.size()])

View File

@@ -35,6 +35,9 @@ var room_code := ""
var in_game := false var in_game := false
## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein. ## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein.
var rematch: Dictionary = {} var rematch: Dictionary = {}
## Raum und Name nach einem Verbindungsabbruch - für "Zurück in Raum XY".
## Ein bewusstes Verlassen löscht es.
var letzter_raum: Dictionary = {}
# ── Server ─────────────────────────────────────────────────────────────────── # ── Server ───────────────────────────────────────────────────────────────────
var dedicated := false var dedicated := false
@@ -100,6 +103,7 @@ func serve(port: int) -> bool:
func leave() -> void: func leave() -> void:
letzter_raum = {}
if multiplayer.multiplayer_peer != null: if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null multiplayer.multiplayer_peer = null
@@ -131,6 +135,8 @@ func _on_connect_fail() -> void:
func _on_server_disconnected() -> void: func _on_server_disconnected() -> void:
if in_game and room_code != "" and room_code != SOLO:
letzter_raum = {"code": room_code, "name": player_name}
multiplayer.multiplayer_peer = null multiplayer.multiplayer_peer = null
lobby.clear() lobby.clear()
owner_id = 0 owner_id = 0
@@ -252,7 +258,7 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
# ── Start ──────────────────────────────────────────────────────────────────── # ── Start ────────────────────────────────────────────────────────────────────
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots. ## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
func start_singleplayer(pname: String, ai_count: int, stufe := 1) -> void: func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5) -> void:
player_name = pname player_name = pname
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
room_code = SOLO room_code = SOLO
@@ -262,16 +268,51 @@ func start_singleplayer(pname: String, ai_count: int, stufe := 1) -> void:
in_game = true in_game = true
lobby_changed.emit() lobby_changed.emit()
game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count, game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count,
"stufe": stufe}) "stufe": stufe, "dice": dice})
## Der Host bittet den Server um den Start. ## Nach einem Abbruch mit demselben Namen zurück in die laufende Partie.
func request_start(ai_count: int, stufe := 1) -> void: func wieder_rein() -> void:
_req_start.rpc_id(1, ai_count, stufe) var r := letzter_raum
letzter_raum = {}
join_room(String(r.code), String(r.name))
## Der Raumbesitzer will nach dem Ende noch eine Partie mit derselben Runde.
func request_rematch() -> void:
_req_rematch.rpc_id(1)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _req_start(ai_count: int, stufe: int) -> void: func _req_rematch() -> void:
if not multiplayer.is_server():
return
var id := multiplayer.get_remote_sender_id()
var code: String = peer_raum.get(id, "")
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty() or not raum.in_game or id != raum.owner:
return
var order: Array = raum.lobby.keys()
order.sort()
if order.size() + int(raum.get("ai", 0)) < 2:
return
raum.bestaetigt = {}
var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": int(raum.get("ai", 0)),
"stufe": int(raum.get("stufe", 1)), "dice": int(raum.get("dice", 5)), "rematch": true}
room_closed.emit(code) # alter Spielknoten weg, auf dem Server
_start_zustellen(code, cfg)
game_started.emit(code, cfg)
print("Raum %s spielt noch einmal." % code)
## Der Host bittet den Server um den Start.
## `dice` unter 5 nutzen nur die Tests, für kurze Partien.
func request_start(ai_count: int, stufe := 1, dice := 5) -> void:
_req_start.rpc_id(1, ai_count, stufe, dice)
@rpc("any_peer", "reliable")
func _req_start(ai_count: int, stufe: int, dice: int) -> void:
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
var id := multiplayer.get_remote_sender_id() var id := multiplayer.get_remote_sender_id()
@@ -284,8 +325,11 @@ func _req_start(ai_count: int, stufe: int) -> void:
if order.size() + ai_count < 2: if order.size() + ai_count < 2:
return return
raum.in_game = true raum.in_game = true
raum.ai = ai_count
raum.stufe = clampi(stufe, 0, 2)
raum.dice = clampi(dice, 1, 5)
var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count, var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count,
"stufe": clampi(stufe, 0, 2)} "stufe": clampi(stufe, 0, 2), "dice": clampi(dice, 1, 5)}
_start_zustellen(code, cfg) _start_zustellen(code, cfg)
game_started.emit(code, cfg) game_started.emit(code, cfg)
@@ -323,7 +367,7 @@ func _start_erhalten() -> void:
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _begin(code: String, cfg: Dictionary) -> void: func _begin(code: String, cfg: Dictionary) -> void:
_start_erhalten.rpc_id(1) _start_erhalten.rpc_id(1)
if in_game and room_code == code: if in_game and room_code == code and not cfg.get("rematch", false):
return # Nachzügler-Paket, die Partie läuft hier schon return # Nachzügler-Paket, die Partie läuft hier schon
in_game = true in_game = true
room_code = code room_code = code

View File

@@ -442,6 +442,27 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void:
arr[i].throw_from(zone.global_transform * off, rng) arr[i].throw_from(zone.global_transform * off, rng)
## Fangnetz: ein Würfel, der über die Bande oder durch den Filz gefallen ist,
## fiele sonst ewig - und das Würfeln käme nie zur Ruhe. Er kommt zurück in
## seine Zone und fällt dort noch einmal. Gibt zurück, ob einer gefangen wurde.
func fange_verirrte(seat: int) -> bool:
var zone: Node3D = zones[seat]
var gefangen := false
for d in dice[seat]:
if d.global_position.y > TABLE_H - 0.3 and d.global_position.y < TABLE_H + 3.0:
continue
var ang := randf() * TAU
var off := Vector3(cos(ang), 0.0, sin(ang)) * ZONE_RAD * 0.3 + Vector3(0, 0.35, 0)
d.freeze = false
d.sleeping = false
d.linear_velocity = Vector3.ZERO
d.angular_velocity = Vector3.ZERO
var t := Transform3D(Basis(), zone.global_transform * off)
d.place(t)
gefangen = true
return gefangen
## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). Ab `von` ## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). Ab `von`
## bleiben die davorliegenden unangetastet. ## bleiben die davorliegenden unangetastet.
func _layout_idle(seat: int, von := 0) -> void: func _layout_idle(seat: int, von := 0) -> void:

View File

@@ -1,6 +1,7 @@
# Tests # Tests
make test make test # alle Prüfungen, etwa 30 Sekunden
make sim # Bot gegen Bot, je Stufe 20 Partien: Runden und Siegquoten
Läuft alles headless: Logik, Nochmal, Bot-Partie (Stufe schwer, 8×), und Läuft alles headless: Logik, Nochmal, Bot-Partie (Stufe schwer, 8×), und
eine echte Mehrspieler-Partie mit Server, Host-Client und Gast-Client - der eine echte Mehrspieler-Partie mit Server, Host-Client und Gast-Client - der
@@ -11,6 +12,10 @@ Fenster.
## Fallen ## Fallen
* **Kein `Engine.time_scale`.** Er staucht auch den Physikschritt; ab etwa 6×
tunneln die Würfel durch den Filz und fallen ewig. Schnell wird es über
`game.tempo = 0` - das streicht nur die Wartezeiten des Ablaufs.
* **Wurzelname.** Der Spielknoten muss auf Server und Client denselben Pfad * **Wurzelname.** Der Spielknoten muss auf Server und Client denselben Pfad
haben (`/root/Main/Raeume/<CODE>/…`). Daran hängt jeder RPC. Die haben (`/root/Main/Raeume/<CODE>/…`). Daran hängt jeder RPC. Die
Mehrspieler-Tests laden deshalb die echte Hauptszene als aktuelle Szene und Mehrspieler-Tests laden deshalb die echte Hauptszene als aktuelle Szene und

View File

@@ -1,5 +1,10 @@
extends Node extends Node
## Gemeinsame Helfer für die Testszenen. Jede Testszene erbt hiervon. ## Gemeinsame Helfer für die Testszenen. Jede Testszene erbt hiervon.
##
## Kein Engine.time_scale: der staucht auch den Physikschritt. Ab etwa 6x
## tunneln die Würfel durch den Filz und fallen ewig - die Partie hängt im
## Würfeln. Schnell wird es über `game.tempo = 0`, das nur die Wartezeiten
## des Ablaufs streicht.
var fehler := 0 var fehler := 0

View File

@@ -1,29 +1,32 @@
extends "res://tests/basis.gd" extends "res://tests/basis.gd"
## Alle Plätze sind Bots, achtfacher Zeitraffer. Läuft die Partie fehlerfrei ## Alle Plätze sind Bots, ohne Wartezeiten, drei Plätze mit je drei Würfeln:
## bis zum Ende? Stufe über "-- 0|1|2". ## eine ganze Partie in Sekunden. Stufe über "-- 0|1|2".
var game: Node var game: Node
func _ready() -> void: func _ready() -> void:
Engine.time_scale = 12.0
add_child(preload("res://scenes/main.tscn").instantiate()) add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().process_frame await get_tree().process_frame
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
Net.start_singleplayer("Tester", 3, int(args[0]) if args.size() > 0 else 1) Net.start_singleplayer("Tester", 2, int(args[0]) if args.size() > 0 else 1, 3)
_lauf() _lauf()
func _lauf() -> void: func _lauf() -> void:
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0) await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
game.tempo = 0.0
game.seats[game.local_seat].ai = true game.seats[game.local_seat].ai = true
# Volle Vorräte: so wirft jeder Bot sicher, und der Topf kommt in Bewegung.
for i in game.seats.size():
game.hand[i] = ["peek", "gag", "count", "bid_up"]
var t := 0.0 var t := 0.0
var gesehen := {} var gesehen := {}
var zaehler := {"Genau": 0, "Topf": 0, " wirft ": 0, "vom Tisch": 0} var zaehler := {"Genau": 0, "Topf": 0, " wirft ": 0, "vom Tisch": 0}
var runden := 0 var runden := 0
var letzte := "" var letzte := ""
var naechste_meldung := 500.0 var naechste_meldung := 500.0
while game.phase != "over" and t < 6000.0: while game.phase != "over" and t < 600.0:
await get_tree().create_timer(0.2).timeout await get_tree().create_timer(0.2).timeout
t += 0.2 t += 0.2
if game.phase != letzte: if game.phase != letzte:
@@ -40,6 +43,20 @@ func _lauf() -> void:
for k in zaehler: for k in zaehler:
if k in z: if k in z:
zaehler[k] += 1 zaehler[k] += 1
if game.phase != "over":
# Wo hängt es? Alles ausgeben, was den Ablauf treibt.
print(" STAND phase=%s turn=%d bid=%s must_bid=%s muenze_laeuft=%s answered=%s" % [
game.phase, game.turn, game.bid, game.must_bid, game._muenze_laeuft, game._answered.keys()])
for i in game.seats.size():
print(" Platz %d %s count=%d alive=%s ai=%s hand=%s" % [i, game.seats[i].name,
game.seats[i].count, game.seats[i].alive, game.seats[i].ai, game.hand.get(i, [])])
for z in game.log_lines:
print(" LOG " + z)
print(" paused=%s stream=%s" % [get_tree().paused, game._stream])
for i in game.seats.size():
for d in game.table.dice_of(i):
print(" Wuerfel Platz %d: pos=%s sleeping=%s freeze=%s settled=%s flat=%.2f" % [
i, d.global_position, d.sleeping, d.freeze, d.is_settled(), d.flatness()])
_pruef("Partie endet (%s, Stufe %d, %d Runden, %.0fs Spielzeit)" % [game.banner, DiceAI.stufe, runden, t], game.phase == "over") _pruef("Partie endet (%s, Stufe %d, %d Runden, %.0fs Spielzeit)" % [game.banner, DiceAI.stufe, runden, t], game.phase == "over")
_pruef("Münzen wurden geworfen (%d)" % zaehler[" wirft "], zaehler[" wirft "] > 0) _pruef("Münzen wurden geworfen (%d)" % zaehler[" wirft "], zaehler[" wirft "] > 0)
_pruef("Topf wurde vergeben (%d)" % zaehler["Topf"], zaehler["Topf"] > 0) _pruef("Topf wurde vergeben (%d)" % zaehler["Topf"], zaehler["Topf"] > 0)

62
tests/genau.gd Normal file
View File

@@ -0,0 +1,62 @@
extends "res://tests/basis.gd"
## Genau und Aufdecken in Etappen. Eigene Szene, damit der Ablauf innerhalb
## der 30 s Zugzeit bleibt - danach spielt der Host den Platz automatisch.
var game: Node
func _ready() -> void:
add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().process_frame
Net.start_singleplayer("Tester", 3, 1)
_lauf()
func _menschen() -> void:
for i in [1, 2, 3]:
game.seats[i].ai = false
func _lauf() -> void:
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
game.tempo = 0.3
await _warte(func(): return game.phase == "bid", 40.0)
var me: int = game.local_seat
_menschen()
game.must_bid.clear()
# --- Genau-Treffer: Ansager verliert, Rufer bekommt einen
game.must_bid.clear()
var n3: int = game._count_face(3)
if n3 == 0:
game._set_die(me, 0, 3)
n3 = 1
var vorher: Array = []
for i in 4:
vorher.append(int(game.seats[i].count))
game._do_bid(me, n3, 3)
await _warte(func(): return game.turn == 1, 5.0)
game._do_exact(1)
await _warte(func(): return game.phase == "reveal", 5.0)
_pruef("Etappe 1: noch nicht gezählt", int(game.reveal_total) == -1)
await _warte(func(): return int(game.reveal_total) >= 0, 3.0)
_pruef("Etappe 2: gezählt", int(game.reveal_total) == n3)
await _warte(func(): return game.banner.begins_with("Genau!"), 3.0)
await _warte(func(): return game.phase != "reveal", 8.0)
_pruef("Genau: Ansager -1, Rufer +1, Rest gleich",
int(game.seats[me].count) == vorher[0] - 1 and int(game.seats[1].count) == vorher[1] + 1
and int(game.seats[2].count) == vorher[2] and int(game.seats[3].count) == vorher[3])
_pruef("Rufer beginnt", game.starter == 1)
# --- Genau daneben
# Erst wenn die neue Ansagerunde wirklich läuft: Platz 1 ist dran und
# noch unbeantwortet. Ein "turn == 1" allein gilt schon beim Würfeln.
await _warte(func(): return game.phase == "bid" and game.turn == 1 and game._answered.is_empty(), 30.0)
var n5: int = game._count_face(5)
game._do_bid(1, mini(n5 + 2, game._total_dice()), 5)
await _warte(func(): return game.turn == 2 and game._answered.is_empty(), 5.0)
var c2: int = game.seats[2].count
game._do_exact(2)
await _warte(func(): return game.banner.begins_with("Daneben"), 8.0)
await _warte(func(): return game.phase != "reveal", 8.0)
_pruef("Genau daneben: nur der Rufer verliert", int(game.seats[2].count) == c2 - 1)
_fertig()

6
tests/genau.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/genau.gd" id="1"]
[node name="Test" type="Node"]
script = ExtResource("1")

View File

@@ -1,7 +1,9 @@
extends "res://tests/basis.gd" extends "res://tests/basis.gd"
## Solopartie headless: Münzen, Genau, Topf, Zählen, Stufe, Tipp-Schranke. ## Solopartie headless: Münzen, Topf, Zählen, Stufe, Tipp-Schranke.
## Die Bots werden zu "Menschen" ohne Peer gemacht - dann wartet der Host ## Die Bots werden zu "Menschen" ohne Peer gemacht - dann wartet der Host
## auf unsere Eingaben, und der Ablauf ist steuerbar. ## auf unsere Eingaben, und der Ablauf ist steuerbar. Alles, was den eigenen
## Zug braucht, kommt zuerst: nach 30 s Spielzeit spielt der Host den Platz
## sonst automatisch. Genau und Aufdecken prüft tests/genau.gd.
var game: Node var game: Node
@@ -20,6 +22,8 @@ func _menschen() -> void:
func _lauf() -> void: func _lauf() -> void:
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0) await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
# Nicht null: die Etappen beim Aufdecken sollen noch einzeln sichtbar sein.
game.tempo = 0.3
await _warte(func(): return game.phase == "bid", 40.0) await _warte(func(): return game.phase == "bid", 40.0)
var me: int = game.local_seat var me: int = game.local_seat
var hud: Node = game.hud var hud: Node = game.hud
@@ -35,6 +39,20 @@ func _lauf() -> void:
await game._do_play(me, "count", 0) await game._do_play(me, "count", 0)
_pruef("Zählen ohne Ansage abgelehnt", game.hand[me].has("count")) _pruef("Zählen ohne Ansage abgelehnt", game.hand[me].has("count"))
# --- Topf: Deckel und Übertrag
game.topf.clear()
game.hand[me] = ["gag", "gag", "gag", "gag", "gag", "gag", "gag"]
for i in 7:
await game._do_play(me, "gag", 0)
_pruef("Topf höchstens %d" % BoosterDB.TOPF_MAX, game.topf.size() == BoosterDB.TOPF_MAX)
_pruef("Rausfallen steht im Protokoll", game.log_lines.any(func(z): return "vom Tisch" in z))
game.hand[2] = ["peek", "peek"]
game._topf_an(2)
_pruef("Gewinner nimmt bis zum Limit, Rest bleibt",
game.hand[2].size() == BoosterDB.HANDLIMIT and game.topf.size() == BoosterDB.TOPF_MAX - 2)
game.topf.clear()
game.must_bid.clear()
# --- Wirkungen beider Seiten # --- Wirkungen beider Seiten
game.bid = {"seat": 1, "count": 1, "face": 4} game.bid = {"seat": 1, "count": 1, "face": 4}
var n4: int = game._count_face(4) var n4: int = game._count_face(4)
@@ -57,19 +75,15 @@ func _lauf() -> void:
game.bid = {} game.bid = {}
game.must_bid.clear() game.must_bid.clear()
# --- Topf: Deckel und Übertrag # --- Fangnetz: ein Würfel unter dem Tisch kommt zurück und kommt zur Ruhe
game.topf.clear() var d0 = game.table.dice_of(me)[0]
game.hand[me] = ["gag", "gag", "gag", "gag", "gag", "gag", "gag"] d0.freeze = false
for i in 7: d0.place(Transform3D(Basis(), Vector3(0, -50.0, 0)))
await game._do_play(me, "gag", 0) _pruef("Fangnetz holt den Würfel zurück", game.table.fange_verirrte(me) and d0.global_position.y > 0.0)
_pruef("Topf höchstens %d" % BoosterDB.TOPF_MAX, game.topf.size() == BoosterDB.TOPF_MAX) d0.place(Transform3D(Basis(), Vector3(0, -50.0, 0)))
_pruef("Rausfallen steht im Protokoll", game.log_lines.any(func(z): return "vom Tisch" in z)) await game._neu_werfen(me, [0])
game.hand[2] = ["peek", "peek"] _pruef("Würfeln endet trotz verirrtem Würfel, Wert gelesen",
game._topf_an(2) d0.global_position.y > 0.5 and game.host_values[me][0] >= 1 and game.host_values[me][0] <= 6)
_pruef("Gewinner nimmt bis zum Limit, Rest bleibt",
game.hand[2].size() == BoosterDB.HANDLIMIT and game.topf.size() == BoosterDB.TOPF_MAX - 2)
game.topf.clear()
game.must_bid.clear()
# --- Protokoll und Tipp-Schranke im HUD # --- Protokoll und Tipp-Schranke im HUD
game._push_state() game._push_state()
@@ -88,39 +102,4 @@ func _lauf() -> void:
hud._touch = false hud._touch = false
await get_tree().create_timer(2.5).timeout await get_tree().create_timer(2.5).timeout
# --- Genau-Treffer: Ansager verliert, Rufer bekommt einen
game.must_bid.clear()
var n3: int = game._count_face(3)
if n3 == 0:
game._set_die(me, 0, 3)
n3 = 1
var vorher: Array = []
for i in 4:
vorher.append(int(game.seats[i].count))
game._do_bid(me, n3, 3)
await _warte(func(): return game.turn == 1, 5.0)
game._do_exact(1)
await _warte(func(): return game.phase == "reveal", 5.0)
_pruef("Etappe 1: noch nicht gezählt", int(game.reveal_total) == -1)
await _warte(func(): return int(game.reveal_total) >= 0, 3.0)
_pruef("Etappe 2: gezählt", int(game.reveal_total) == n3)
await _warte(func(): return game.banner.begins_with("Genau!"), 3.0)
await _warte(func(): return game.phase != "reveal", 8.0)
_pruef("Genau: Ansager -1, Rufer +1, Rest gleich",
int(game.seats[me].count) == vorher[0] - 1 and int(game.seats[1].count) == vorher[1] + 1
and int(game.seats[2].count) == vorher[2] and int(game.seats[3].count) == vorher[3])
_pruef("Rufer beginnt", game.starter == 1)
# --- Genau daneben
await _warte(func(): return game.phase == "bid", 20.0)
_menschen()
await _warte(func(): return game.turn == 1, 5.0)
var n5: int = game._count_face(5)
game._do_bid(1, mini(n5 + 2, game._total_dice()), 5)
await _warte(func(): return game.turn == 2, 5.0)
var c2: int = game.seats[2].count
game._do_exact(2)
await _warte(func(): return game.banner.begins_with("Daneben"), 8.0)
await _warte(func(): return game.phase != "reveal", 8.0)
_pruef("Genau daneben: nur der Rufer verliert", int(game.seats[2].count) == c2 - 1)
_fertig() _fertig()

View File

@@ -16,7 +16,6 @@ var _code_datei := ""
func _ready() -> void: func _ready() -> void:
Engine.time_scale = 8.0
_rng.randomize() _rng.randomize()
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
rolle = args[0] rolle = args[0]
@@ -33,7 +32,7 @@ func _lauf() -> void:
f.store_string(Net.room_code) f.store_string(Net.room_code)
f.close() f.close()
await _warte(func(): return Net.lobby.size() >= 2, 80.0) await _warte(func(): return Net.lobby.size() >= 2, 80.0)
Net.request_start(1, 1) Net.request_start(1, 1, 2)
else: else:
await _beitreten() await _beitreten()
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 120.0) await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 120.0)
@@ -46,9 +45,9 @@ func _lauf() -> void:
var t := 0.0 var t := 0.0
var gewartet := 0.0 var gewartet := 0.0
while t < 4400.0 and is_instance_valid(game) and game.phase != "over": while t < 1400.0 and is_instance_valid(game) and game.phase != "over":
await get_tree().create_timer(0.2).timeout await get_tree().create_timer(0.1).timeout
t += 0.2 t += 0.1
if not is_instance_valid(game): if not is_instance_valid(game):
break break
if game.phase != _letzte_phase: if game.phase != _letzte_phase:
@@ -63,14 +62,14 @@ func _lauf() -> void:
if game.phase != "bid" or game.turn != me or not game.seats[me].alive: if game.phase != "bid" or game.turn != me or not game.seats[me].alive:
gewartet = 0.0 gewartet = 0.0
continue continue
gewartet += 0.2 gewartet += 0.1
if gewartet < 0.6: if gewartet < 0.2:
continue continue
var hand: Array = game.hand.get(me, []) var hand: Array = game.hand.get(me, [])
if not hand.is_empty() and (not BoosterDB.needs_bid(hand[0]) or not game.bid.is_empty()) and _rng.randf() < 0.7: if not hand.is_empty() and (not BoosterDB.needs_bid(hand[0]) or not game.bid.is_empty()) and _rng.randf() < 0.7:
game._on_booster_chosen(hand[0], 0) game._on_booster_chosen(hand[0], 0)
wuerfe += 1 wuerfe += 1
await get_tree().create_timer(2.5).timeout await get_tree().create_timer(0.4).timeout
if not is_instance_valid(game) or game.turn != me or game.phase != "bid": if not is_instance_valid(game) or game.turn != me or game.phase != "bid":
continue continue
var total := 0 var total := 0
@@ -88,11 +87,22 @@ func _lauf() -> void:
game._on_exact_pressed() game._on_exact_pressed()
genau += 1 genau += 1
zuege += 1 zuege += 1
gewartet = -2.0 gewartet = -1.5
_pruef("%s: Partie endet über RPC (Runden=%d Züge=%d Würfe=%d Genau=%d)" % [rolle, runden, zuege, wuerfe, genau], _pruef("%s: Partie endet über RPC (Runden=%d Züge=%d Würfe=%d Genau=%d)" % [rolle, runden, zuege, wuerfe, genau],
is_instance_valid(game) and game.phase == "over") is_instance_valid(game) and game.phase == "over")
if rolle == "join": if rolle == "join":
_pruef("join: Wiedereinstieg hat geklappt", wieder_da) _pruef("join: Wiedereinstieg hat geklappt", wieder_da)
# Nochmal: der Besitzer bittet darum, beide bekommen eine frische Partie.
var alt: Node = game
await get_tree().create_timer(0.5).timeout
if Net.is_owner:
Net.request_rematch()
var neu := await _warte(func(): game = _find(get_tree().root, "Game"); return game != null and game != alt, 20.0)
var laeuft := false
if neu:
laeuft = await _warte(func(): return game.phase == "bid" or game.phase == "roll", 20.0)
_pruef("%s: Nochmal im Netz bringt eine neue Partie (Platz %d)" % [rolle, game.local_seat if neu else -1],
neu and laeuft and game.local_seat >= 0)
if is_instance_valid(game): if is_instance_valid(game):
print("%s: '%s'" % [rolle, game.banner]) print("%s: '%s'" % [rolle, game.banner])
for i in game.seats.size(): for i in game.seats.size():
@@ -106,15 +116,19 @@ func _beitreten() -> void:
Net.join_room(FileAccess.get_file_as_string(_code_datei).strip_edges(), "Gast") Net.join_room(FileAccess.get_file_as_string(_code_datei).strip_edges(), "Gast")
## Verbindung kappen, frisches Menü, mit demselben Namen wieder beitreten. ## Verbindung reisst ab (kein bewusstes Verlassen), frisches Menü, dann der
## Weg des Knopfs "Zurück in Raum XY".
func _wiederkommen() -> void: func _wiederkommen() -> void:
var alter_platz: int = game.local_seat var alter_platz: int = game.local_seat
var alte_zuege := zuege var alte_zuege := zuege
Net.leave() var code := Net.room_code
get_tree().change_scene_to_file("res://scenes/main.tscn") multiplayer.multiplayer_peer.close()
await get_tree().create_timer(1.5).timeout if Net.in_game:
Net._on_server_disconnected() # der Verlust-Handler, wie bei echtem Abbruch
await get_tree().create_timer(1.0).timeout
_pruef("join: Raum nach Abbruch gemerkt", Net.letzter_raum.get("code", "") == code)
game = null game = null
await _beitreten() Net.wieder_rein()
var ok := await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 60.0) var ok := await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 60.0)
if not ok: if not ok:
_pruef("join: nach Rückkehr kommt ein Spiel", false) _pruef("join: nach Rückkehr kommt ein Spiel", false)

View File

@@ -6,27 +6,34 @@ var game: Node
func _ready() -> void: func _ready() -> void:
Engine.time_scale = 8.0
var t := 0.0 var t := 0.0
var runden := 0 var runden := 0
var letzte := "" var letzte := ""
while t < 4500.0: var zuletzt := "" # letzter gesehener Stand - die Clients gehen sofort,
await get_tree().create_timer(0.5).timeout # dann räumt der Server den Raum ab, bevor wir gucken
t += 0.5 while t < 1500.0:
await get_tree().create_timer(0.2).timeout
t += 0.2
if game == null: if game == null:
game = _find(get_tree().root, "Game") game = _find(get_tree().root, "Game")
if game != null:
game.tempo = 0.0
continue continue
if not is_instance_valid(game) or game.phase == "over": if not is_instance_valid(game):
break break
if game.phase != letzte: if game.phase != letzte:
letzte = game.phase letzte = game.phase
if letzte == "roll": if letzte == "roll":
runden += 1 runden += 1
if is_instance_valid(game): var staende: Array = game.seats.map(func(x): return "%s=%d" % [x.name, x.count])
print("SERVER: Ende %s '%s', Runden=%d" % [game.phase, game.banner, runden]) zuletzt = "%s '%s' Runden=%d %s" % [game.phase, game.banner, runden, staende]
for i in game.seats.size(): if game.phase == "over":
print("SERVER: Platz %d %s count=%d alive=%s" % [i, game.seats[i].name, game.seats[i].count, game.seats[i].alive]) break
var zurueck: bool = game.log_lines.any(func(z): return "ist zurück" in z) print("SERVER: Ende %s" % zuletzt)
print("SERVER: Rückkehr im Protokoll=%s" % zurueck) var alt: Node = game
await get_tree().create_timer(80.0).timeout var neu := await _warte(func(): game = _find(get_tree().root, "Game"); return game != null and game != alt, 15.0)
if neu:
game.tempo = 0.0
print("SERVER: Nochmal-Partie aufgebaut=%s" % neu)
await get_tree().create_timer(15.0).timeout
get_tree().quit() get_tree().quit()

View File

@@ -9,6 +9,7 @@ func _ready() -> void:
await get_tree().create_timer(0.5).timeout await get_tree().create_timer(0.5).timeout
Net.start_singleplayer("Tester", 2, 0) Net.start_singleplayer("Tester", 2, 0)
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0) await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
game.tempo = 0.0
await _warte(func(): return game.phase == "bid", 40.0) await _warte(func(): return game.phase == "bid", 40.0)
_pruef("Erste Partie läuft, Stufe leicht", DiceAI.stufe == 0 and game.seats.size() == 3) _pruef("Erste Partie läuft, Stufe leicht", DiceAI.stufe == 0 and game.seats.size() == 3)
game._on_rematch() game._on_rematch()

View File

@@ -1,51 +1,66 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Alle headless Tests. Aufruf: make test (oder: bash tests/run.sh) # Alle headless Tests, parallel. Aufruf: make test (oder: bash tests/run.sh)
# Der Mehrspieler-Test braucht einen freien Port; 8000 ist auf Rechnern mit # Ein Test fällt sofort, wenn ein Skriptfehler im Log auftaucht - nicht erst
# laufendem Entwicklungsserver oft belegt, deshalb 8765. # nach dem Timeout. Der Mehrspieler-Test braucht einen freien Port; 8000 ist
# auf Rechnern mit laufendem Entwicklungsserver oft belegt, deshalb 8765.
set -u set -u
GODOT="${GODOT:-$HOME/bin/godot}" GODOT="${GODOT:-$HOME/bin/godot}"
PORT="${PORT:-8765}" PORT="${PORT:-8765}"
# Ein Rest vom letzten Lauf oder ein fremder Dienst darf den Test nicht
# still auf einen falschen Server schicken: freien Port suchen.
while ss -ltn 2>/dev/null | grep -q ":$PORT "; do PORT=$((PORT + 1)); done
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
ergebnis=0 start=$(date +%s)
lauf() { # name, timeout, argumente... declare -A PID
starte() { # name, timeout, argumente... -> Hintergrund
local name="$1"; local zeit="$2"; shift 2 local name="$1"; local zeit="$2"; shift 2
echo "=== $name" timeout "$zeit" "$GODOT" --headless --path . "res://tests/${name%%:*}.tscn" -- "$@" > "$TMP/$name.log" 2>&1 &
timeout "$zeit" "$GODOT" --headless --path . "res://tests/$name.tscn" -- "$@" > "$TMP/$name.log" 2>&1 PID[$name]=$!
local rc=$?
grep -E "^(OK |FEHL| |TEST:)" "$TMP/$name.log"
local fehler; fehler=$(grep -c "SCRIPT ERROR" "$TMP/$name.log")
[ "$fehler" -gt 0 ] && { echo " $fehler Skriptfehler im Log: $TMP/$name.log"; rc=1; }
[ $rc -ne 0 ] && ergebnis=1
} }
lauf logik 200 starte logik 90
lauf nochmal 120 starte genau 90
lauf bots 900 2 starte nochmal 60
starte bots 90 2
echo "=== mehrspieler"
rm -f "$TMP/code.txt" rm -f "$TMP/code.txt"
timeout 700 "$GODOT" --headless --path . res://tests/mp_start.tscn -- server "$PORT" > "$TMP/srv.log" 2>&1 & starte mp_start:srv 200 server "$PORT"
SRV=$!
sleep 3
PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" timeout 680 "$GODOT" --headless --path . res://tests/mp_start.tscn -- host "$TMP/code.txt" > "$TMP/host.log" 2>&1 &
H=$!
sleep 2 sleep 2
PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" timeout 680 "$GODOT" --headless --path . res://tests/mp_start.tscn -- join "$TMP/code.txt" > "$TMP/join.log" 2>&1 & PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:host 190 host "$TMP/code.txt"
J=$! sleep 1
wait $H; rc_h=$? PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:join 190 join "$TMP/code.txt"
wait $J; rc_j=$?
# Der Server beendet sich nach dem Ende selbst. Ihn abzuschiessen würde # Warten, aber bei Skriptfehlern sofort abbrechen.
# seine letzten Zeilen verlieren - Ausgabe in eine Datei ist gepuffert. while :; do
for _ in $(seq 1 40); do kill -0 $SRV 2>/dev/null || break; sleep 1; done offen=0
kill $SRV 2>/dev/null; wait $SRV 2>/dev/null for name in "${!PID[@]}"; do
grep -E "^(OK |FEHL|SERVER:|TEST:)" "$TMP/host.log" "$TMP/join.log" "$TMP/srv.log" | sed 's|^.*/||' pid=${PID[$name]}
pfad=$(cat "$TMP/host.log" "$TMP/join.log" "$TMP/srv.log" | grep -c "Failed to get path") kill -0 "$pid" 2>/dev/null || continue
fehler=$(cat "$TMP/host.log" "$TMP/join.log" "$TMP/srv.log" | grep -c "SCRIPT ERROR") if grep -q "SCRIPT ERROR" "$TMP/$name.log" 2>/dev/null; then
echo " RPC-Pfadfehler=$pfad Skriptfehler=$fehler (Logs: $TMP)" kill "$pid" 2>/dev/null
[ $rc_h -ne 0 ] || [ $rc_j -ne 0 ] || [ "$pfad" -gt 0 ] || [ "$fehler" -gt 0 ] && ergebnis=1 continue
fi
offen=$((offen + 1))
done
[ "$offen" -eq 0 ] && break
sleep 1
done
wait 2>/dev/null
ergebnis=0
for name in logik genau nochmal bots mp_start:host mp_start:join mp_start:srv; do
log="$TMP/$name.log"
echo "=== $name"
grep -E "^(OK |FEHL|SERVER:| |TEST:)" "$log"
fehler=$(grep -c "SCRIPT ERROR" "$log")
[ "$fehler" -gt 0 ] && { echo " $fehler Skriptfehler:"; grep -A2 "SCRIPT ERROR" "$log" | head -6; ergebnis=1; }
if [ "$name" != "mp_start:srv" ] && ! grep -q "TEST: fertig, Fehler = 0" "$log"; then ergebnis=1; fi
done
pfad=$(cat "$TMP"/mp_start:*.log | grep -c "Failed to get path")
[ "$pfad" -gt 0 ] && { echo " RPC-Pfadfehler: $pfad"; ergebnis=1; }
echo echo
[ $ergebnis -eq 0 ] && echo "ALLE TESTS GRÜN" || echo "TESTS ROT (Logs: $TMP)" echo "Dauer: $(( $(date +%s) - start ))s Logs: $TMP"
[ $ergebnis -eq 0 ] && echo "ALLE TESTS GRÜN" || echo "TESTS ROT"
exit $ergebnis exit $ergebnis

71
tests/sim.gd Normal file
View File

@@ -0,0 +1,71 @@
extends "res://tests/basis.gd"
## Bot gegen Bot, viele Partien hintereinander: Rundenzahl und Siegquoten je
## Platz. Kein Test, ein Messgerät für die Regler in ai.gd.
## Aufruf: make sim (oder einzeln: -- <stufe> <partien>)
var game: Node
var stufe := 1
var partien := 20
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
stufe = int(args[0])
if args.size() > 1:
partien = int(args[1])
add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().process_frame
_lauf()
func _lauf() -> void:
var runden_summe := 0
var siege := {}
var genau := 0
var wuerfe := 0
var laengste := 0
for n in partien:
Net.start_singleplayer("Tester", 2, stufe, 3)
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null and game.phase != "lobby", 20.0)
game.tempo = 0.0
game.seats[game.local_seat].ai = true
var runden := 0
var letzte := ""
var gesehen := {}
var t := 0.0
while game.phase != "over" and t < 300.0:
await get_tree().create_timer(0.1).timeout
t += 0.1
if game.phase != letzte:
letzte = game.phase
if letzte == "roll":
runden += 1
for z in game.log_lines:
if gesehen.has(z):
continue
gesehen[z] = true
if "Genau" in z:
genau += 1
if " wirft " in z:
wuerfe += 1
runden_summe += runden
laengste = maxi(laengste, runden)
var sieger := ""
for s in game.seats:
if s.alive:
sieger = String(s.name)
siege[sieger] = int(siege.get(sieger, 0)) + 1
# Aufräumen für die nächste Partie: wie "Nochmal", nur ohne Menü.
game._quitting = true
Net.leave()
var raum: Node = game.get_parent().get_parent().get_parent()
raum.get_parent().remove_child(raum)
raum.queue_free()
game = null
await get_tree().process_frame
print("SIM Stufe %d: %d Partien, Ø %.1f Runden (längste %d), Ø %.1f Würfe, Ø %.1f Genau-Rufe" % [
stufe, partien, float(runden_summe) / partien, laengste, float(wuerfe) / partien, float(genau) / partien])
for k in siege:
print("SIM Stufe %d: %s gewinnt %d×" % [stufe, k, siege[k]])
get_tree().quit()

6
tests/sim.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/sim.gd" id="1"]
[node name="Test" type="Node"]
script = ExtResource("1")