From b9d6b115c01718870c94f7e87f079f72882e67eb Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 16 Sep 2026 15:04:02 +0200 Subject: [PATCH] update --- Makefile | 6 +- README.md | 17 +- scripts/ai.gd | 11 +- scripts/aufdecken.gd | 104 +++++++++++++ scripts/game.gd | 327 +++++---------------------------------- scripts/hud.gd | 6 +- scripts/main.gd | 8 +- scripts/menu.gd | 24 ++- scripts/muenzen.gd | 212 +++++++++++++++++++++++++ scripts/net.gd | 60 ++++++- scripts/table.gd | 21 +++ tests/README.md | 7 +- tests/basis.gd | 5 + tests/bots.gd | 27 +++- tests/genau.gd | 62 ++++++++ tests/genau.tscn | 6 + tests/logik.gd | 79 ++++------ tests/mp_client.gd | 42 +++-- tests/mp_server.gd | 31 ++-- tests/nochmal_laeufer.gd | 1 + tests/run.sh | 87 ++++++----- tests/sim.gd | 71 +++++++++ tests/sim.tscn | 6 + 23 files changed, 795 insertions(+), 425 deletions(-) create mode 100644 scripts/aufdecken.gd create mode 100644 scripts/muenzen.gd create mode 100644 tests/genau.gd create mode 100644 tests/genau.tscn create mode 100644 tests/sim.gd create mode 100644 tests/sim.tscn diff --git a/Makefile b/Makefile index 97a3745..249fd45 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev server prod logs down test +.PHONY: dev server prod logs down test sim GODOT ?= $(HOME)/bin/godot @@ -16,6 +16,10 @@ dev: test: 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 ─────────────────────────────────────────────────────────────── # Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst. prod: diff --git a/README.md b/README.md index 52cda2b..3a6f5c2 100644 --- a/README.md +++ b/README.md @@ -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 demselben Namen und Code wieder bei, bekommt er seinen Platz zurück: der 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. Nur er stellt die Bots ein und startet. * 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 Spannung auf, statt dass alles auf einmal dasteht. -Nach dem Ende gibt es solo ein **Nochmal** mit denselben Bots. Im Netz führt -der Weg zurück ins Menü. +Nach dem Ende gibt es ein **Nochmal** mit derselben Runde - solo sofort, im +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. @@ -237,7 +242,7 @@ das ist seine Grundwirkung, und Wappen macht daraus nur eine 6. die Münzen zu seiner Zone. * Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es 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 @@ -258,12 +263,14 @@ vermutlich haarscharf, rufen sie „Genau". | `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart | | `scripts/boosters.gd` | Autoload: Münzkatalog mit Grund- und Wappenwirkung | | `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/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage | | `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) | | `scripts/hud.gd` | Spiel-UI | | `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 | Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten diff --git a/scripts/ai.gd b/scripts/ai.gd index 9973bfa..dcfdb40 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -55,10 +55,14 @@ static var stufe := 1 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}, - {"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. const FRECH := 0.12 +## Wie oft er eine gute Genau-Gelegenheit auch wirklich nutzt. +const GENAU_LUST := 0.35 static func _regler() -> Dictionary: @@ -85,7 +89,10 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary, if not must_bid: # Liegt die Ansage vermutlich haarscharf, lohnt "Genau" mehr als die # 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"} var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07) if hold < doubt and (raise_.is_empty() or raise_.p < 0.32): diff --git a/scripts/aufdecken.gd b/scripts/aufdecken.gd new file mode 100644 index 0000000..1e58798 --- /dev/null +++ b/scripts/aufdecken.gd @@ -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 diff --git a/scripts/game.gd b/scripts/game.gd index 100455a..6998ab0 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -13,6 +13,11 @@ const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist, var table: GameTable 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 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), "count": int(s.count), "alive": bool(s.alive), "verwaist": false}) else: + var dice := int(cfg.get("dice", START_DICE)) # Tests spielen mit weniger var peers: Array = cfg.peers for p in peers: 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): 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. var me := multiplayer.get_unique_id() @@ -299,6 +305,10 @@ func _await_settled(list: Array, timeout := 7.0) -> void: continue var ok := true 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): if d.freeze: continue @@ -311,136 +321,45 @@ func _await_settled(list: Array, timeout := 7.0) -> void: 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: - var muenzen: Array = hand.get(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]) + muenzen._zieh_muenze(seat) -## 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 phase != "bid" or seat != turn or _answered.has(seat): - 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() + await muenzen._do_play(seat, id, target) func _wirkung(seat: int, id: String, target: int, stark: bool) -> void: - var vals: Array = host_values[seat] - 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 + await muenzen._wirkung(seat, id, target, stark) func _count_face(face: int) -> int: - var n := 0 - for i in _alive_seats(): - for v in host_values[i]: - if v == face: - n += 1 - return n + return muenzen._count_face(face) -## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen. -func _zufalls_idx(seat: int, n: int) -> Array: - 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 _bot_muenze(seat: int, a: Dictionary) -> bool: + return await muenzen._bot_muenze(seat, a) -func _zufalls_paare(seat: int, n: int) -> Array: - var out: Array = [] - for k: int in _zufalls_idx(seat, n): - out.append([k, host_values[seat][k]]) - return out +func _topf_an(winner: int) -> void: + muenzen._topf_an(winner) -## 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 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 +func _phase_reveal(result: Dictionary) -> void: + await aufdecken._phase_reveal(result) + + +func _all_values() -> Array: + return aufdecken._all_values() + ## Würfel neu werfen und die Augenzahl danach übernehmen. @@ -518,61 +437,6 @@ func _phase_bid() -> Dictionary: 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. func _apply_action(seat: int, a: Dictionary) -> Dictionary: 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 -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. func _sleep(sec: float) -> void: if not _alive(): 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: @@ -983,11 +734,15 @@ func _on_pause_changed(on: bool) -> void: _push_state() -## Noch eine Solopartie mit denselben Bots. Der Wunsch wandert über Net ins -## neu geladene Menü, das dann sofort startet. +## Noch eine Partie mit denselben Bots. Solo wandert der Wunsch über Net ins +## neu geladene Menü, das dann sofort startet. Im Netz bittet der Raumbesitzer +## den Server, den Raum neu aufzusetzen. func _on_rematch() -> void: if _quitting: return + if Net.room_code != Net.SOLO: + Net.request_rematch() + return var bots := 0 for s in seats: if s.ai: diff --git a/scripts/hud.gd b/scripts/hud.gd index 63da134..0011f2b 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -748,9 +748,9 @@ func update_state(st: Dictionary, my_seat: int) -> void: banner_label.text = "" if st.phase == "over" else st.banner if st.phase == "over" and not over_slot.visible: over_title.text = String(st.banner) - # Nochmal gibt es nur solo. Im Netz müsste der Server den Raum neu - # aufsetzen; bis dahin geht es zurück ins Menü. - over_again.visible = Net.room_code == Net.SOLO + # Nochmal: solo immer, im Netz nur für den Raumbesitzer - der Server + # setzt den Raum dann für alle neu auf. + over_again.visible = Net.room_code == Net.SOLO or Net.is_owner over_slot.visible = true var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive diff --git a/scripts/main.gd b/scripts/main.gd index e7de75d..3fe4da2 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -49,7 +49,10 @@ func _unhandled_key_input(event: InputEvent) -> void: func _raum_bauen(code: String, cfg: Dictionary) -> void: if raeume.has_node(code): - return + # Ein Nochmal ersetzt die alte Partie; alles andere ist ein Doppel. + if not cfg.get("rematch", false): + return + _raum_abbauen(code) if menu != null: menu.queue_free() menu = null @@ -103,9 +106,12 @@ func _wiedereinstieg(code: String, peer: int, pname: String) -> void: 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: var raum := raeume.get_node_or_null(NodePath(code)) if raum != null: + raeume.remove_child(raum) raum.queue_free() diff --git a/scripts/menu.gd b/scripts/menu.gd index ccfd385..66453ec 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -10,6 +10,7 @@ var current := "main" # Einzelspieler var solo_bots: NumberRow +var zurueck_btn: Button ## "Zurück in Raum XY" nach einem Abbruch var solo_stufe: OptionButton var lobby_stufe: OptionButton @@ -62,6 +63,10 @@ func _show(which: String) -> void: for key: String in screens: screens[key].visible = key == which _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": _refresh() @@ -88,6 +93,12 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label: 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 ## der Bots ein. func _stufen_wahl() -> OptionButton: @@ -170,6 +181,9 @@ func _title(box: VBoxContainer) -> void: func _build_main() -> Control: var box := _box(360) _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("Mehrspieler", box, _show.bind("multi")) _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."], ["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: var h := HBoxContainer.new() h.add_theme_constant_override("separation", 12) @@ -206,7 +222,13 @@ func _build_regeln() -> Control: h.add_child(t) box.add_child(h) _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: diff --git a/scripts/muenzen.gd b/scripts/muenzen.gd new file mode 100644 index 0000000..ad09925 --- /dev/null +++ b/scripts/muenzen.gd @@ -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()]) diff --git a/scripts/net.gd b/scripts/net.gd index c44526c..b815316 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -35,6 +35,9 @@ var room_code := "" var in_game := false ## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein. 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 ─────────────────────────────────────────────────────────────────── var dedicated := false @@ -100,6 +103,7 @@ func serve(port: int) -> bool: func leave() -> void: + letzter_raum = {} if multiplayer.multiplayer_peer != null: multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer = null @@ -131,6 +135,8 @@ func _on_connect_fail() -> 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 lobby.clear() owner_id = 0 @@ -252,7 +258,7 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void: # ── Start ──────────────────────────────────────────────────────────────────── ## 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 multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() room_code = SOLO @@ -262,16 +268,51 @@ func start_singleplayer(pname: String, ai_count: int, stufe := 1) -> void: in_game = true lobby_changed.emit() 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. -func request_start(ai_count: int, stufe := 1) -> void: - _req_start.rpc_id(1, ai_count, stufe) +## Nach einem Abbruch mit demselben Namen zurück in die laufende Partie. +func wieder_rein() -> void: + 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") -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(): return 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: return 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, - "stufe": clampi(stufe, 0, 2)} + "stufe": clampi(stufe, 0, 2), "dice": clampi(dice, 1, 5)} _start_zustellen(code, cfg) game_started.emit(code, cfg) @@ -323,7 +367,7 @@ func _start_erhalten() -> void: @rpc("authority", "reliable") func _begin(code: String, cfg: Dictionary) -> void: _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 in_game = true room_code = code diff --git a/scripts/table.gd b/scripts/table.gd index ee475d5..c114c31 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -442,6 +442,27 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void: 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` ## bleiben die davorliegenden unangetastet. func _layout_idle(seat: int, von := 0) -> void: diff --git a/tests/README.md b/tests/README.md index ca871e0..bbbe963 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,7 @@ # 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 eine echte Mehrspieler-Partie mit Server, Host-Client und Gast-Client - der @@ -11,6 +12,10 @@ Fenster. ## 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 haben (`/root/Main/Raeume//…`). Daran hängt jeder RPC. Die Mehrspieler-Tests laden deshalb die echte Hauptszene als aktuelle Szene und diff --git a/tests/basis.gd b/tests/basis.gd index bd7515b..5ceeb98 100644 --- a/tests/basis.gd +++ b/tests/basis.gd @@ -1,5 +1,10 @@ extends Node ## 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 diff --git a/tests/bots.gd b/tests/bots.gd index c3d9341..ffb6f8b 100644 --- a/tests/bots.gd +++ b/tests/bots.gd @@ -1,29 +1,32 @@ extends "res://tests/basis.gd" -## Alle Plätze sind Bots, achtfacher Zeitraffer. Läuft die Partie fehlerfrei -## bis zum Ende? Stufe über "-- 0|1|2". +## Alle Plätze sind Bots, ohne Wartezeiten, drei Plätze mit je drei Würfeln: +## eine ganze Partie in Sekunden. Stufe über "-- 0|1|2". var game: Node func _ready() -> void: - Engine.time_scale = 12.0 add_child(preload("res://scenes/main.tscn").instantiate()) await get_tree().process_frame 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() func _lauf() -> void: 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 + # 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 gesehen := {} var zaehler := {"Genau": 0, "Topf": 0, " wirft ": 0, "vom Tisch": 0} var runden := 0 var letzte := "" 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 t += 0.2 if game.phase != letzte: @@ -40,6 +43,20 @@ func _lauf() -> void: for k in zaehler: if k in z: 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("Münzen wurden geworfen (%d)" % zaehler[" wirft "], zaehler[" wirft "] > 0) _pruef("Topf wurde vergeben (%d)" % zaehler["Topf"], zaehler["Topf"] > 0) diff --git a/tests/genau.gd b/tests/genau.gd new file mode 100644 index 0000000..a141bf7 --- /dev/null +++ b/tests/genau.gd @@ -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() diff --git a/tests/genau.tscn b/tests/genau.tscn new file mode 100644 index 0000000..657cb1d --- /dev/null +++ b/tests/genau.tscn @@ -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") diff --git a/tests/logik.gd b/tests/logik.gd index 9374134..84b25da 100644 --- a/tests/logik.gd +++ b/tests/logik.gd @@ -1,7 +1,9 @@ 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 -## 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 @@ -20,6 +22,8 @@ func _menschen() -> void: func _lauf() -> void: 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) var me: int = game.local_seat var hud: Node = game.hud @@ -35,6 +39,20 @@ func _lauf() -> void: await game._do_play(me, "count", 0) _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 game.bid = {"seat": 1, "count": 1, "face": 4} var n4: int = game._count_face(4) @@ -57,19 +75,15 @@ func _lauf() -> void: game.bid = {} game.must_bid.clear() - # --- 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() + # --- Fangnetz: ein Würfel unter dem Tisch kommt zurück und kommt zur Ruhe + var d0 = game.table.dice_of(me)[0] + d0.freeze = false + d0.place(Transform3D(Basis(), Vector3(0, -50.0, 0))) + _pruef("Fangnetz holt den Würfel zurück", game.table.fange_verirrte(me) and d0.global_position.y > 0.0) + d0.place(Transform3D(Basis(), Vector3(0, -50.0, 0))) + await game._neu_werfen(me, [0]) + _pruef("Würfeln endet trotz verirrtem Würfel, Wert gelesen", + d0.global_position.y > 0.5 and game.host_values[me][0] >= 1 and game.host_values[me][0] <= 6) # --- Protokoll und Tipp-Schranke im HUD game._push_state() @@ -88,39 +102,4 @@ func _lauf() -> void: hud._touch = false 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() diff --git a/tests/mp_client.gd b/tests/mp_client.gd index f8ac822..d496e39 100644 --- a/tests/mp_client.gd +++ b/tests/mp_client.gd @@ -16,7 +16,6 @@ var _code_datei := "" func _ready() -> void: - Engine.time_scale = 8.0 _rng.randomize() var args := OS.get_cmdline_user_args() rolle = args[0] @@ -33,7 +32,7 @@ func _lauf() -> void: f.store_string(Net.room_code) f.close() await _warte(func(): return Net.lobby.size() >= 2, 80.0) - Net.request_start(1, 1) + Net.request_start(1, 1, 2) else: await _beitreten() 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 gewartet := 0.0 - while t < 4400.0 and is_instance_valid(game) and game.phase != "over": - await get_tree().create_timer(0.2).timeout - t += 0.2 + while t < 1400.0 and is_instance_valid(game) and game.phase != "over": + await get_tree().create_timer(0.1).timeout + t += 0.1 if not is_instance_valid(game): break 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: gewartet = 0.0 continue - gewartet += 0.2 - if gewartet < 0.6: + gewartet += 0.1 + if gewartet < 0.2: continue 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: game._on_booster_chosen(hand[0], 0) 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": continue var total := 0 @@ -88,11 +87,22 @@ func _lauf() -> void: game._on_exact_pressed() genau += 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], is_instance_valid(game) and game.phase == "over") if rolle == "join": _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): print("%s: '%s'" % [rolle, game.banner]) 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") -## 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: var alter_platz: int = game.local_seat var alte_zuege := zuege - Net.leave() - get_tree().change_scene_to_file("res://scenes/main.tscn") - await get_tree().create_timer(1.5).timeout + var code := Net.room_code + multiplayer.multiplayer_peer.close() + 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 - await _beitreten() + Net.wieder_rein() var ok := await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 60.0) if not ok: _pruef("join: nach Rückkehr kommt ein Spiel", false) diff --git a/tests/mp_server.gd b/tests/mp_server.gd index eb48b77..e380362 100644 --- a/tests/mp_server.gd +++ b/tests/mp_server.gd @@ -6,27 +6,34 @@ var game: Node func _ready() -> void: - Engine.time_scale = 8.0 var t := 0.0 var runden := 0 var letzte := "" - while t < 4500.0: - await get_tree().create_timer(0.5).timeout - t += 0.5 + var zuletzt := "" # letzter gesehener Stand - die Clients gehen sofort, + # dann räumt der Server den Raum ab, bevor wir gucken + while t < 1500.0: + await get_tree().create_timer(0.2).timeout + t += 0.2 if game == null: game = _find(get_tree().root, "Game") + if game != null: + game.tempo = 0.0 continue - if not is_instance_valid(game) or game.phase == "over": + if not is_instance_valid(game): break if game.phase != letzte: letzte = game.phase if letzte == "roll": runden += 1 - if is_instance_valid(game): - print("SERVER: Ende %s '%s', Runden=%d" % [game.phase, game.banner, runden]) - for i in game.seats.size(): - print("SERVER: Platz %d %s count=%d alive=%s" % [i, game.seats[i].name, game.seats[i].count, game.seats[i].alive]) - var zurueck: bool = game.log_lines.any(func(z): return "ist zurück" in z) - print("SERVER: Rückkehr im Protokoll=%s" % zurueck) - await get_tree().create_timer(80.0).timeout + var staende: Array = game.seats.map(func(x): return "%s=%d" % [x.name, x.count]) + zuletzt = "%s '%s' Runden=%d %s" % [game.phase, game.banner, runden, staende] + if game.phase == "over": + break + print("SERVER: Ende %s" % zuletzt) + var alt: Node = game + 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() diff --git a/tests/nochmal_laeufer.gd b/tests/nochmal_laeufer.gd index ed118ce..9e480fc 100644 --- a/tests/nochmal_laeufer.gd +++ b/tests/nochmal_laeufer.gd @@ -9,6 +9,7 @@ func _ready() -> void: await get_tree().create_timer(0.5).timeout Net.start_singleplayer("Tester", 2, 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) _pruef("Erste Partie läuft, Stufe leicht", DiceAI.stufe == 0 and game.seats.size() == 3) game._on_rematch() diff --git a/tests/run.sh b/tests/run.sh index 29674a6..0c9bc30 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,51 +1,66 @@ #!/usr/bin/env bash -# Alle headless Tests. Aufruf: make test (oder: bash tests/run.sh) -# Der Mehrspieler-Test braucht einen freien Port; 8000 ist auf Rechnern mit -# laufendem Entwicklungsserver oft belegt, deshalb 8765. +# Alle headless Tests, parallel. Aufruf: make test (oder: bash tests/run.sh) +# Ein Test fällt sofort, wenn ein Skriptfehler im Log auftaucht - nicht erst +# nach dem Timeout. Der Mehrspieler-Test braucht einen freien Port; 8000 ist +# auf Rechnern mit laufendem Entwicklungsserver oft belegt, deshalb 8765. set -u GODOT="${GODOT:-$HOME/bin/godot}" 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)" 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 - echo "=== $name" - timeout "$zeit" "$GODOT" --headless --path . "res://tests/$name.tscn" -- "$@" > "$TMP/$name.log" 2>&1 - 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 + timeout "$zeit" "$GODOT" --headless --path . "res://tests/${name%%:*}.tscn" -- "$@" > "$TMP/$name.log" 2>&1 & + PID[$name]=$! } -lauf logik 200 -lauf nochmal 120 -lauf bots 900 2 - -echo "=== mehrspieler" +starte logik 90 +starte genau 90 +starte nochmal 60 +starte bots 90 2 rm -f "$TMP/code.txt" -timeout 700 "$GODOT" --headless --path . res://tests/mp_start.tscn -- server "$PORT" > "$TMP/srv.log" 2>&1 & -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=$! +starte mp_start:srv 200 server "$PORT" 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 & -J=$! -wait $H; rc_h=$? -wait $J; rc_j=$? -# Der Server beendet sich nach dem Ende selbst. Ihn abzuschiessen würde -# seine letzten Zeilen verlieren - Ausgabe in eine Datei ist gepuffert. -for _ in $(seq 1 40); do kill -0 $SRV 2>/dev/null || break; sleep 1; done -kill $SRV 2>/dev/null; wait $SRV 2>/dev/null -grep -E "^(OK |FEHL|SERVER:|TEST:)" "$TMP/host.log" "$TMP/join.log" "$TMP/srv.log" | sed 's|^.*/||' -pfad=$(cat "$TMP/host.log" "$TMP/join.log" "$TMP/srv.log" | grep -c "Failed to get path") -fehler=$(cat "$TMP/host.log" "$TMP/join.log" "$TMP/srv.log" | grep -c "SCRIPT ERROR") -echo " RPC-Pfadfehler=$pfad Skriptfehler=$fehler (Logs: $TMP)" -[ $rc_h -ne 0 ] || [ $rc_j -ne 0 ] || [ "$pfad" -gt 0 ] || [ "$fehler" -gt 0 ] && ergebnis=1 +PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:host 190 host "$TMP/code.txt" +sleep 1 +PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:join 190 join "$TMP/code.txt" + +# Warten, aber bei Skriptfehlern sofort abbrechen. +while :; do + offen=0 + for name in "${!PID[@]}"; do + pid=${PID[$name]} + kill -0 "$pid" 2>/dev/null || continue + if grep -q "SCRIPT ERROR" "$TMP/$name.log" 2>/dev/null; then + kill "$pid" 2>/dev/null + 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 -[ $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 diff --git a/tests/sim.gd b/tests/sim.gd new file mode 100644 index 0000000..5a32d67 --- /dev/null +++ b/tests/sim.gd @@ -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: -- ) + +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() diff --git a/tests/sim.tscn b/tests/sim.tscn new file mode 100644 index 0000000..eb1b5e4 --- /dev/null +++ b/tests/sim.tscn @@ -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")