This commit is contained in:
team3
2026-09-16 13:21:56 +02:00
parent 7979d5c336
commit 3be22351a3
25 changed files with 734 additions and 195 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: dev server prod logs down .PHONY: dev server prod logs down test
GODOT ?= $(HOME)/bin/godot GODOT ?= $(HOME)/bin/godot
@@ -12,6 +12,10 @@ server:
dev: dev:
PIRATE_DICE_SERVER=ws://127.0.0.1:8000 $(GODOT) --path . PIRATE_DICE_SERVER=ws://127.0.0.1:8000 $(GODOT) --path .
# Alle headless Tests, inklusive Mehrspieler auf Port 8765. Siehe tests/README.md.
test:
GODOT=$(GODOT) bash tests/run.sh
# ── 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

@@ -38,6 +38,10 @@ Die Partie läuft nicht beim Host, sondern auf `dice.marha.de`:
* Broadcasts (`rpc`) würden alle Räume treffen. Deshalb geht alles gezielt per * Broadcasts (`rpc`) würden alle Räume treffen. Deshalb geht alles gezielt per
`rpc_id` an die Peers des eigenen Raums. `rpc_id` an die Peers des eigenen Raums.
* Der Server sitzt nicht mit am Tisch: `local_seat` bleibt −1, kein HUD. * Der Server sitzt nicht mit am Tisch: `local_seat` bleibt −1, kein HUD.
* **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.
* 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
@@ -224,7 +228,13 @@ das ist seine Grundwirkung, und Wappen macht daraus nur eine 6.
* Wer dran ist, hat einen leuchtenden Zonenrand, eine hinterlegte Zeile in * Wer dran ist, hat einen leuchtenden Zonenrand, eine hinterlegte Zeile in
der Spielerliste und eine Zeile über der Ansageleiste. der Spielerliste und eine Zeile über der Ansageleiste.
* Über einer Münze erscheint sofort ihre Erklärung. Sie bleibt, solange die * Über einer Münze erscheint sofort ihre Erklärung. Sie bleibt, solange die
Maus darauf liegt - auch wenn die Runde weiterläuft. Maus darauf liegt - auch wenn die Runde weiterläuft. Auf dem Handy öffnet
der erste Tipp die Erklärung, der zweite wirft.
* Unten rechts läuft das **Protokoll**: wer den Topf nimmt, was vom Tisch
fällt, wer geht und wer zurückkommt.
* Eine geworfene Münze fällt drehend in die Tischmitte. Wappen-Münzen tragen
einen geprägten Ring und leuchten stärker. Nimmt jemand den Topf, fliegen
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**.
@@ -253,6 +263,7 @@ vermutlich haarscharf, rufen sie „Genau".
| `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` |
| `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

@@ -34,6 +34,7 @@ var _muenze_laeuft := false ## eine Münze wird gerade aufgelöst
var _wurf_nr := 0 ## zählt die Würfe übers Spiel, damit das HUD neue erkennt var _wurf_nr := 0 ## zählt die Würfe übers Spiel, damit das HUD neue erkennt
var _klang_phase := "" ## letzte Phase, für die ein Klang lief var _klang_phase := "" ## letzte Phase, für die ein Klang lief
var _gezaehlt_gezeigt := false ## Hervorhebung beim Aufdecken lief schon var _gezaehlt_gezeigt := false ## Hervorhebung beim Aufdecken lief schon
var _topf_ziel := -1 ## wer den Topf gerade genommen hat, für den Flug der Münzen
var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde
var log_lines: Array = [] var log_lines: Array = []
var phase := "lobby" var phase := "lobby"
@@ -56,13 +57,20 @@ func _ready() -> void:
func setup(cfg: Dictionary) -> void: func setup(cfg: Dictionary) -> void:
DiceAI.stufe = int(cfg.get("stufe", 1)) DiceAI.stufe = int(cfg.get("stufe", 1))
seats.clear() seats.clear()
var peers: Array = cfg.peers if cfg.has("seats"):
for p in peers: # Rückkehrer: die laufende Aufstellung, Platz für Platz, in der
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false, # Reihenfolge des Servers - daran hängen alle Indizes im Zustand.
"count": START_DICE, "alive": true}) for s in cfg.seats:
for i in int(cfg.ai): seats.append({"peer": int(s.peer), "name": String(s.name), "ai": bool(s.ai),
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true, "count": int(s.count), "alive": bool(s.alive), "verwaist": false})
"count": START_DICE, "alive": true}) else:
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})
for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
"count": START_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()
@@ -93,7 +101,7 @@ func setup(cfg: Dictionary) -> void:
# Die Startmünze zieht nur der Host. Beim Client kommt sie mit dem # Die Startmünze zieht nur der Host. Beim Client kommt sie mit dem
# ersten Zustand, sonst läge dort kurz eine andere. # ersten Zustand, sonst läge dort kurz eine andere.
hand[i] = [BoosterDB.zieh(rng)] if multiplayer.is_server() else [] hand[i] = [BoosterDB.zieh(rng)] if multiplayer.is_server() else []
table.set_dice_count(i, START_DICE) table.set_dice_count(i, seats[i].count)
table.set_dice_visible(i, false) table.set_dice_visible(i, false)
table.set_cup_down(i, i != local_seat) table.set_cup_down(i, i != local_seat)
host_values.append([]) host_values.append([])
@@ -110,10 +118,44 @@ func _on_peer_lost(peer: int) -> void:
return return
seats[seat].ai = true seats[seat].ai = true
seats[seat].peer = 0 seats[seat].peer = 0
seats[seat].verwaist = true
_log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name) _log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name)
_push_state() _push_state()
## Nur Server: ein Rückkehrer mit diesem Namen bekommt seinen Platz zurück.
## Gibt die Aufstellung für seinen Client zurück, oder {} wenn es keinen
## verwaisten Platz mit dem Namen gibt.
func wiedereinstieg(peer: int, pname: String) -> Dictionary:
for i in seats.size():
var s = seats[i]
if not s.verwaist or String(s.name).to_lower() != pname.to_lower():
continue
s.peer = peer
s.ai = false
s.verwaist = false
_log("%s ist zurück und übernimmt wieder." % s.name)
var aufstellung: Array = []
for t in seats:
aufstellung.append({"peer": t.peer, "name": t.name, "ai": t.ai,
"count": t.count, "alive": t.alive})
_nachliefern(i)
return {"seats": aufstellung, "stufe": DiceAI.stufe}
return {}
## Dem Rückkehrer Zustand und Würfellage schicken - kurz verzögert, damit
## sein Spielknoten steht, bevor das erste Paket dort ankommt.
func _nachliefern(seat: int) -> void:
await _sleep(0.6)
if not _alive():
return
_refresh_labels()
_push_state()
if _peer_live(seats[seat].peer):
_dice_snapshot.rpc_id(seats[seat].peer, _pack(seat))
func _refresh_labels() -> void: func _refresh_labels() -> void:
for i in seats.size(): for i in seats.size():
var s = seats[i] var s = seats[i]
@@ -214,6 +256,7 @@ func _phase_roll() -> void:
gezaehlt.clear() gezaehlt.clear()
verlauf.clear() verlauf.clear()
must_bid.clear() must_bid.clear()
_topf_ziel = -1
banner = "" banner = ""
_log("--- Neue Runde ---") _log("--- Neue Runde ---")
@@ -670,6 +713,7 @@ func _topf_an(winner: int) -> void:
return return
var h: Array = hand.get(winner, []) var h: Array = hand.get(winner, [])
var genommen := 0 var genommen := 0
_topf_ziel = winner
while not topf.is_empty() and h.size() < BoosterDB.HANDLIMIT: while not topf.is_empty() and h.size() < BoosterDB.HANDLIMIT:
h.append(String(topf.pop_front().id)) h.append(String(topf.pop_front().id))
genommen += 1 genommen += 1
@@ -832,6 +876,8 @@ func _state_for(seat: int) -> Dictionary:
"actual": reveal_total if phase == "reveal" else 0, "actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(), "bids": _seat_bids(),
"verlauf": verlauf.duplicate(true), "verlauf": verlauf.duplicate(true),
"topf_an": _topf_ziel,
"log": log_lines.duplicate(),
"hand": (hand.get(seat, []) as Array).duplicate(), "hand": (hand.get(seat, []) as Array).duplicate(),
} }
@@ -865,7 +911,7 @@ func _apply_state(st: Dictionary) -> void:
table.set_cup_down(i, seats[i].alive) table.set_cup_down(i, seats[i].alive)
table.set_dice_visible(local_seat, true) table.set_dice_visible(local_seat, true)
_refresh_labels() _refresh_labels()
table.set_topf(st.get("boosts", [])) table.set_topf(st.get("boosts", []), int(st.get("topf_an", -1)))
if phase == "reveal" and int(st.get("actual", -1)) >= 0 and not _gezaehlt_gezeigt: if phase == "reveal" and int(st.get("actual", -1)) >= 0 and not _gezaehlt_gezeigt:
_gezaehlt_gezeigt = true _gezaehlt_gezeigt = true
if not bid.is_empty(): if not bid.is_empty():

View File

@@ -61,8 +61,13 @@ var info_name: Label
var info_desc: Label var info_desc: Label
var info_stark: Label var info_stark: Label
var wurf_slot: CenterContainer var wurf_slot: CenterContainer
var wurf_icon: MuenzIcon
var wurf_label: Label var wurf_label: Label
var log_box: VBoxContainer
var _log_sig := ""
## Auf dem Handy gibt es kein Überfahren: der erste Tipp zeigt die Erklärung,
## der zweite wirft. Am Rechner bleibt alles beim Klick.
var _touch := DisplayServer.is_touchscreen_available()
var _tipp_id := "" ## Münze, deren Erklärung nach dem ersten Tipp offen ist
var timer_bid: Label var timer_bid: Label
var timer_boost: Label var timer_boost: Label
var target_row: HBoxContainer var target_row: HBoxContainer
@@ -456,21 +461,25 @@ func _build() -> void:
hand_panel.visible = false hand_panel.visible = false
# --- Booster-Overlay # --- Booster-Overlay
# --- Geworfene Münze, links unter der Liste der gelegten Münzen. Die # --- Wurfansage, links unter dem Topf. Die Münze selbst fällt in 3D auf
# Tischmitte ist besetzt: dort stehen die Becher und das Gebot. # den Tisch; hier steht nur, wer was geworfen hat und wie es fiel.
wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 300) wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 200)
wurf_slot.visible = false wurf_slot.visible = false
var wv := VBoxContainer.new()
wv.mouse_filter = Control.MOUSE_FILTER_IGNORE
wv.add_theme_constant_override("separation", 6)
wurf_slot.add_child(wv)
wurf_icon = MuenzIcon.new("", ACCENT, 104.0)
wurf_icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
wv.add_child(wurf_icon)
wurf_label = _label("", 16, ACCENT) wurf_label = _label("", 16, ACCENT)
wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
wv.add_child(wurf_label) wurf_slot.add_child(wurf_label)
# --- Protokoll unten rechts: was der Topf tut, wer geht, wer zurückkommt.
var lp := _panel()
# Am unteren Rand verankert und nach oben wachsend - so gross wie der Text.
_place(lp, 1, 1, 1, 1, -376, -124, -16, -124)
lp.grow_horizontal = Control.GROW_DIRECTION_BEGIN
lp.grow_vertical = Control.GROW_DIRECTION_BEGIN
root.add_child(lp)
log_box = VBoxContainer.new()
log_box.add_theme_constant_override("separation", 2)
lp.add_child(log_box)
# --- Erklärung beim Überfahren. Ein eigenes Feld statt Tooltip: das kommt # --- Erklärung beim Überfahren. Ein eigenes Feld statt Tooltip: das kommt
# sofort und verschwindet nicht, wenn die Runde weiterläuft. # sofort und verschwindet nicht, wenn die Runde weiterläuft.
@@ -733,6 +742,7 @@ func update_state(st: Dictionary, my_seat: int) -> void:
_zeichne_hand(st) _zeichne_hand(st)
_zeichne_mitte(st, my_seat) _zeichne_mitte(st, my_seat)
_zeichne_verlauf(st, my_seat) _zeichne_verlauf(st, my_seat)
_zeichne_log(st)
# Am Ende steht der Sieger im Endbildschirm; das Banner darüber wäre doppelt. # Am Ende steht der Sieger im Endbildschirm; das Banner darüber wäre doppelt.
banner_label.text = "" if st.phase == "over" else st.banner banner_label.text = "" if st.phase == "over" else st.banner
@@ -1045,54 +1055,48 @@ func _pruefe_wurf(neu: Dictionary, st: Dictionary) -> void:
_short(String(st.seats[seat].name))) _short(String(st.seats[seat].name)))
## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Drehung ## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Münze
## ist nur die Ansicht dazu und endet auf der richtigen Seite. ## fällt in 3D auf den Tisch; hier steht der Text dazu, kurz und dann weg.
func _zeig_wurf(id: String, stark: bool, wer: String) -> void: func _zeig_wurf(id: String, stark: bool, wer: String) -> void:
var b: Dictionary = BoosterDB.get_booster(id) var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty(): if b.is_empty():
return return
wurf_icon.id = id wurf_label.text = "%s wirft %s\n%s" % [wer, b.name,
wurf_icon.kind_col = _kind_col(b) "Wappen - " + String(b.get("stark", "")) if stark else "Zahl - Grundwirkung"]
wurf_icon.seite = 0 wurf_label.add_theme_color_override("font_color", ACCENT if stark else DIM)
wurf_icon.scale = Vector2.ONE
wurf_icon.queue_redraw()
wurf_label.text = "%s wirft\n%s" % [wer, b.name]
wurf_label.add_theme_color_override("font_color", DIM)
wurf_slot.visible = true wurf_slot.visible = true
Klang.muenze() Klang.muenze()
if _wurf_tween != null and _wurf_tween.is_valid(): if _wurf_tween != null and _wurf_tween.is_valid():
_wurf_tween.kill() _wurf_tween.kill()
_wurf_tween = create_tween() _wurf_tween = create_tween()
_wurf_tween.tween_method(_dreh, 0.0, TAU * 2.5, 0.8) _wurf_tween.tween_interval(2.6)
_wurf_tween.tween_callback(_wurf_ende.bind(stark, b))
_wurf_tween.tween_interval(1.6)
_wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false) _wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false)
## Die Münze wird waagerecht gestaucht; bei negativer Breite sieht man die ## Die letzten Zeilen des Spielprotokolls.
## Rückseite. Das ist eine Drehung, ohne dass etwas in 3D stehen muss. func _zeichne_log(st: Dictionary) -> void:
func _dreh(winkel: float) -> void: var zeilen: Array = st.get("log", [])
var c := cos(winkel) var sig := str(zeilen)
wurf_icon.scale.x = maxf(0.06, absf(c)) if sig == _log_sig:
var seite := 0 if c >= 0.0 else 1 return
if wurf_icon.seite != seite: _log_sig = sig
wurf_icon.seite = seite _clear(log_box)
wurf_icon.queue_redraw() var ab := maxi(0, zeilen.size() - 5)
for i in range(ab, zeilen.size()):
var l := _label(String(zeilen[i]), 13, ACCENT if i == zeilen.size() - 1 else DIM)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(340, 0)
log_box.add_child(l)
func _wurf_ende(stark: bool, b: Dictionary) -> void:
wurf_icon.scale.x = 1.0
wurf_icon.seite = 0 if stark else 1
wurf_icon.queue_redraw()
if stark:
wurf_label.text = "%s\nWappen - verstärkt" % b.name
wurf_label.add_theme_color_override("font_color", ACCENT)
else:
wurf_label.text = "%s\nZahl - Grundwirkung" % b.name
wurf_label.add_theme_color_override("font_color", DIM)
func _pick(id: String) -> void: func _pick(id: String) -> void:
# Handy: erster Tipp öffnet nur die Erklärung (die kommt über den
# nachgeahmten Mauszeiger von selbst), der zweite wirft wirklich.
if _touch and _tipp_id != id:
_tipp_id = id
return
_tipp_id = ""
if not BoosterDB.needs_target(id) or _my_values.is_empty(): if not BoosterDB.needs_target(id) or _my_values.is_empty():
booster_chosen.emit(id, 0) booster_chosen.emit(id, 0)
boost_slot.visible = false boost_slot.visible = false

View File

@@ -19,6 +19,7 @@ func _ready() -> void:
Net.game_started.connect(_raum_bauen) Net.game_started.connect(_raum_bauen)
Net.room_closed.connect(_raum_abbauen) Net.room_closed.connect(_raum_abbauen)
Net.rejoin_requested.connect(_wiedereinstieg)
Net.server_closed.connect(_back_to_menu) Net.server_closed.connect(_back_to_menu)
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
@@ -88,6 +89,20 @@ func _raum_bauen(code: String, cfg: Dictionary) -> void:
game.setup(cfg) game.setup(cfg)
## Nur Server: ein Rückkehrer klopft an. Gibt es seinen verwaisten Platz
## noch, übernimmt er ihn wieder vom Bot.
func _wiedereinstieg(code: String, peer: int, pname: String) -> void:
var game := raeume.get_node_or_null(NodePath(code + "/Container/Viewport/Game"))
if game == null:
Net.abweisen(peer, "In dem Raum läuft schon eine Partie.")
return
var cfg: Dictionary = game.wiedereinstieg(peer, pname)
if cfg.is_empty():
Net.abweisen(peer, "In dem Raum läuft schon eine Partie, und kein Platz heißt %s." % pname)
return
Net.rejoin_annehmen(code, peer, pname, cfg)
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:

View File

@@ -13,6 +13,9 @@ extends Node
signal lobby_changed signal lobby_changed
signal game_started(code: String, cfg: Dictionary) signal game_started(code: String, cfg: Dictionary)
signal room_closed(code: String) signal room_closed(code: String)
## Nur Server: jemand will in eine laufende Partie zurück. Der Raum prüft,
## ob es einen verwaisten Platz mit dem Namen gibt.
signal rejoin_requested(code: String, peer: int, pname: String)
signal connection_failed(reason: String) signal connection_failed(reason: String)
signal server_closed signal server_closed
@@ -172,7 +175,9 @@ func _req_join(code: String, pname: String) -> void:
_abgewiesen.rpc_id(id, "Den Code %s gibt es nicht." % code) _abgewiesen.rpc_id(id, "Den Code %s gibt es nicht." % code)
return return
if raum.in_game: if raum.in_game:
_abgewiesen.rpc_id(id, "In dem Raum läuft schon eine Partie.") # Kein Neuling, aber vielleicht ein Rückkehrer: dessen Platz spielt
# gerade ein Bot weiter. Der Raum entscheidet.
rejoin_requested.emit(code, id, pname.substr(0, 16))
return return
if raum.lobby.size() >= MAX_HUMANS: if raum.lobby.size() >= MAX_HUMANS:
_abgewiesen.rpc_id(id, "Der Raum ist voll.") _abgewiesen.rpc_id(id, "Der Raum ist voll.")
@@ -182,6 +187,23 @@ func _req_join(code: String, pname: String) -> void:
_lobby_senden(code) _lobby_senden(code)
## Rückkehrer aufnehmen: in die Lobby eintragen und mit der laufenden
## Aufstellung starten lassen. `cfg` beschreibt alle Plätze, nicht nur Peers.
func rejoin_annehmen(code: String, peer: int, pname: String, cfg: Dictionary) -> void:
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty():
return
raum.lobby[peer] = pname
peer_raum[peer] = code
raum.bestaetigt.erase(peer)
_begin.rpc_id(peer, code, cfg)
print("%s kehrt in Raum %s zurück." % [pname, code])
func abweisen(peer: int, grund: String) -> void:
_abgewiesen.rpc_id(peer, grund)
## Ein Broadcast würde alle Räume treffen, also gezielt an die eigenen Peers. ## Ein Broadcast würde alle Räume treffen, also gezielt an die eigenen Peers.
func _lobby_senden(code: String) -> void: func _lobby_senden(code: String) -> void:
var raum: Dictionary = raeume.get(code, {}) var raum: Dictionary = raeume.get(code, {})

View File

@@ -476,10 +476,12 @@ func freeze_dice(seat: int) -> void:
## Der Topf liegt als Häufchen Messing in der Tischmitte. Jede Münze trägt ## Der Topf liegt als Häufchen Messing in der Tischmitte. Jede Münze trägt
## ihre Wurfnummer; was nicht mehr in der Liste steht, ist beim Gewinner. ## ihre Wurfnummer; was nicht mehr in der Liste steht, ist beim Gewinner -
## dorthin fliegt sie dann auch (`ziel`), sonst verschwindet sie einfach.
## Lage und Drehung kommen aus der Nummer, damit Host und Clients dasselbe ## Lage und Drehung kommen aus der Nummer, damit Host und Clients dasselbe
## Häufchen sehen. ## Häufchen sehen. Wappen-Münzen tragen einen geprägten Ring und leuchten
func set_topf(liste: Array) -> void: ## stärker: so sieht man den Wurfausgang auf dem Tisch, nicht nur im Text.
func set_topf(liste: Array, ziel := -1) -> void:
var bleibt := {} var bleibt := {}
for m in liste: for m in liste:
var nr := int(m.get("nr", 0)) var nr := int(m.get("nr", 0))
@@ -490,21 +492,43 @@ func set_topf(liste: Array) -> void:
r.seed = nr * 7919 r.seed = nr * 7919
var ang := r.randf() * TAU var ang := r.randf() * TAU
var rad := r.randf_range(0.30, 0.55) var rad := r.randf_range(0.30, 0.55)
var ziel := Vector3(cos(ang) * rad, TABLE_H + 0.006, sin(ang) * rad) var wappen: bool = m.get("stark", false)
var lage := Vector3(cos(ang) * rad, TABLE_H + 0.007, sin(ang) * rad)
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
mi.mesh = _muenz_form() mi.mesh = _muenz_form()
mi.material_override = _muenz_stoff() mi.material_override = _muenz_stoff(wappen)
mi.position = ziel + Vector3(0, 0.35, 0) mi.position = lage + Vector3(0, 0.45, 0)
mi.rotation.y = r.randf() * TAU mi.rotation.y = r.randf() * TAU
add_child(mi) add_child(mi)
if wappen:
var ring := MeshInstance3D.new()
ring.mesh = _praegung()
ring.material_override = _praegung_stoff()
ring.position = Vector3(0, 0.008, 0)
mi.add_child(ring)
topf_muenzen[nr] = mi topf_muenzen[nr] = mi
# Fällt aus der Hand auf den Tisch. # Fällt drehend aus der Hand und bleibt flach liegen.
create_tween().tween_property(mi, "position", ziel, 0.35) \ var tw := create_tween().set_parallel(true)
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) tw.tween_property(mi, "position", lage, 0.55).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tw.tween_property(mi, "rotation:x", TAU * 3.0, 0.55).from(0.0)
for nr in topf_muenzen.keys(): for nr in topf_muenzen.keys():
if not bleibt.has(nr): if bleibt.has(nr):
topf_muenzen[nr].queue_free() continue
topf_muenzen.erase(nr) var mi: MeshInstance3D = topf_muenzen[nr]
topf_muenzen.erase(nr)
if ziel < 0 or ziel >= zones.size():
mi.queue_free()
continue
# Zum Gewinner fliegen, dann weg.
var hin: Vector3 = zones[ziel].global_position + Vector3(0, 0.25, 0)
var tw := create_tween()
tw.tween_property(mi, "global_position", hin, 0.6).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
tw.tween_callback(mi.queue_free)
static var _muenz_mat_wappen: StandardMaterial3D
static var _praeg_mesh: TorusMesh
static var _praeg_mat: StandardMaterial3D
static func _muenz_form() -> CylinderMesh: static func _muenz_form() -> CylinderMesh:
@@ -519,7 +543,7 @@ static func _muenz_form() -> CylinderMesh:
return _muenz_mesh return _muenz_mesh
static func _muenz_stoff() -> StandardMaterial3D: static func _muenz_stoff(wappen: bool) -> StandardMaterial3D:
if _muenz_mat == null: if _muenz_mat == null:
_muenz_mat = StandardMaterial3D.new() _muenz_mat = StandardMaterial3D.new()
_muenz_mat.albedo_color = Color(0.80, 0.62, 0.26) _muenz_mat.albedo_color = Color(0.80, 0.62, 0.26)
@@ -528,7 +552,27 @@ static func _muenz_stoff() -> StandardMaterial3D:
_muenz_mat.emission_enabled = true _muenz_mat.emission_enabled = true
_muenz_mat.emission = Color(0.55, 0.40, 0.12) _muenz_mat.emission = Color(0.55, 0.40, 0.12)
_muenz_mat.emission_energy_multiplier = 0.35 _muenz_mat.emission_energy_multiplier = 0.35
return _muenz_mat _muenz_mat_wappen = _muenz_mat.duplicate()
_muenz_mat_wappen.emission_energy_multiplier = 0.9
return _muenz_mat_wappen if wappen else _muenz_mat
static func _praegung() -> TorusMesh:
if _praeg_mesh == null:
_praeg_mesh = TorusMesh.new()
_praeg_mesh.inner_radius = 0.035
_praeg_mesh.outer_radius = 0.065
_praeg_mesh.rings = 20
_praeg_mesh.ring_segments = 6
return _praeg_mesh
static func _praegung_stoff() -> StandardMaterial3D:
if _praeg_mat == null:
_praeg_mat = StandardMaterial3D.new()
_praeg_mat.albedo_color = Color(0.20, 0.14, 0.05)
_praeg_mat.roughness = 0.8
return _praeg_mat
## Beim Zählen leuchten die passenden Würfel nacheinander auf, mit einem ## Beim Zählen leuchten die passenden Würfel nacheinander auf, mit einem

View File

@@ -1,81 +0,0 @@
extends Node
const ORT := "/tmp/claude-1000/-home-arbeit-projects-pirate-dice/00de2cf5-7327-4fce-b543-2b4db6d3b775/scratchpad"
var game: Node
var rolle := ""
var runden := 0
var wuerfe := 0
var genau := 0
var _letzte_phase := ""
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
Engine.time_scale = 8.0
_rng.randomize()
rolle = OS.get_cmdline_user_args()[0]
add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().create_timer(0.3).timeout
_lauf()
func _find(n: Node, w: String) -> Node:
if n.name == w: return n
for c in n.get_children():
var r := _find(c, w)
if r != null: return r
return null
func _warte(cond: Callable, sek: float) -> bool:
var t := 0.0
while t < sek:
if cond.call(): return true
await get_tree().create_timer(0.1).timeout
t += 0.1
return false
func _lauf() -> void:
if rolle == "host":
Net.create_room("Host")
await _warte(func(): return Net.room_code != "", 40.0)
var f := FileAccess.open(ORT + "/code.txt", FileAccess.WRITE)
f.store_string(Net.room_code); f.close()
await _warte(func(): return Net.lobby.size() >= 2, 80.0)
Net.request_start(1, 1)
else:
await _warte(func(): return FileAccess.file_exists(ORT + "/code.txt"), 80.0)
await get_tree().create_timer(0.5).timeout
Net.join_room(FileAccess.get_file_as_string(ORT + "/code.txt").strip_edges(), "Gast")
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 120.0)
if game == null:
print("%s: kein Spiel" % rolle); get_tree().quit(1); return
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
if not is_instance_valid(game): break
if game.phase != _letzte_phase:
_letzte_phase = game.phase
if game.phase == "roll": runden += 1
var me: int = game.local_seat
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: 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
if not is_instance_valid(game) or game.turn != me or game.phase != "bid": continue
var total := 0
for s in game.seats: total += int(s.count)
var a: Dictionary = DiceAI.choose_action(game.my_values, total, game.bid, false, _rng)
if a.type == "challenge" and not game.bid.is_empty() and _rng.randf() < 0.3:
a = {"type": "exact"}
match a.type:
"bid": game._on_bid_pressed(int(a.count), int(a.face))
"challenge": game._on_challenge_pressed()
"exact":
game._on_exact_pressed(); genau += 1
gewartet = -2.0
if is_instance_valid(game):
print("%s: Ende %s '%s' Platz=%d Runden=%d Wuerfe=%d Genau=%d" % [rolle, game.phase, game.banner, game.local_seat, runden, wuerfe, genau])
for i in game.seats.size():
print("%s: Platz %d %s count=%d alive=%s" % [rolle, i, game.seats[i].name, game.seats[i].count, game.seats[i].alive])
else:
print("%s: Spiel weg, Runden=%d" % [rolle, runden])
get_tree().quit()

View File

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

View File

@@ -1,32 +0,0 @@
extends Node
const ORT := "/tmp/claude-1000/-home-arbeit-projects-pirate-dice/00de2cf5-7327-4fce-b543-2b4db6d3b775/scratchpad"
var game: Node
func _ready() -> void:
Engine.time_scale = 8.0
add_child(preload("res://scenes/main.tscn").instantiate())
_lauf()
func _find(n: Node, w: String) -> Node:
if n.name == w: return n
for c in n.get_children():
var r := _find(c, w)
if r != null: return r
return null
func _lauf() -> void:
var t := 0.0
var runden := 0
var letzte := ""
while t < 4500.0:
await get_tree().create_timer(0.5).timeout; t += 0.5
if game == null:
game = _find(get_tree().root, "Game"); continue
if not is_instance_valid(game) or game.phase == "over": break
if game.phase != letzte:
letzte = game.phase
if letzte == "roll": runden += 1
if is_instance_valid(game):
print("SERVER: Ende %s '%s' nach %.0fs, Runden=%d" % [game.phase, game.banner, t, 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])
# Den Clients Zeit lassen, das Ende zu sehen und selbst zu gehen.
await get_tree().create_timer(80.0).timeout
get_tree().quit()

View File

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

25
tests/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Tests
make test
Läuft alles headless: Logik, Nochmal, Bot-Partie (Stufe schwer, 8×), und
eine echte Mehrspieler-Partie mit Server, Host-Client und Gast-Client - der
Gast steigt in Runde zwei aus und wieder ein.
Die Screenshots-Prüfungen (Look, Layout) sind nicht dabei; die braucht ein
Fenster.
## Fallen
* **Wurzelname.** Der Spielknoten muss auf Server und Client denselben Pfad
haben (`/root/Main/Raeume/<CODE>/…`). Daran hängt jeder RPC. Die
Mehrspieler-Tests laden deshalb die echte Hauptszene als aktuelle Szene und
hängen ihren Läufer daneben unter `root`. Wer die Hauptszene unter eine
Testwurzel hängt, bekommt auf der Gegenseite nur
`Requested node was not found` - und der Server spielt die Menschen per
Zeitablauf selbst, was wie eine laufende Partie aussieht.
* **Port 8000** ist auf Entwicklungsrechnern oft belegt. `PORT=… make test`.
* **Lambdas** in GDScript kopieren lokale Variablen. Eine Zuweisung darin
landet nicht draußen; die Tests halten `game` deshalb als Feld.
* **Ausgabe** erscheint erst, wenn der Prozess regulär endet. Ein per
`timeout` abgeschossener Client verliert seine letzten Zeilen.

35
tests/basis.gd Normal file
View File

@@ -0,0 +1,35 @@
extends Node
## Gemeinsame Helfer für die Testszenen. Jede Testszene erbt hiervon.
var fehler := 0
func _find(n: Node, wunsch: String) -> Node:
if n.name == wunsch:
return n
for c in n.get_children():
var r := _find(c, wunsch)
if r != null:
return r
return null
func _warte(cond: Callable, sek: float) -> bool:
var t := 0.0
while t < sek:
if cond.call():
return true
await get_tree().create_timer(0.05).timeout
t += 0.05
return false
func _pruef(name: String, ok: bool) -> void:
if not ok:
fehler += 1
print("%s %s" % ["OK " if ok else "FEHL", name])
func _fertig() -> void:
print("TEST: fertig, Fehler = %d" % fehler)
get_tree().quit(1 if fehler > 0 else 0)

47
tests/bots.gd Normal file
View File

@@ -0,0 +1,47 @@
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".
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)
_lauf()
func _lauf() -> void:
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
game.seats[game.local_seat].ai = true
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:
await get_tree().create_timer(0.2).timeout
t += 0.2
if game.phase != letzte:
letzte = game.phase
if letzte == "roll":
runden += 1
if t >= naechste_meldung:
naechste_meldung += 500.0
print(" t=%.0fs Runden=%d Würfel=%d Phase=%s" % [t, runden, game._total_dice(), game.phase])
for z in game.log_lines:
if gesehen.has(z):
continue
gesehen[z] = true
for k in zaehler:
if k in z:
zaehler[k] += 1
_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)
print(" Genau-Rufe=%d, vom Tisch=%d, Topf am Ende=%d" % [zaehler["Genau"], zaehler["vom Tisch"], game.topf.size()])
_fertig()

6
tests/bots.tscn Normal file
View File

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

126
tests/logik.gd Normal file
View File

@@ -0,0 +1,126 @@
extends "res://tests/basis.gd"
## Solopartie headless: Münzen, Genau, 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.
var game: Node
func _ready() -> void:
add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().process_frame
Net.start_singleplayer("Tester", 3, 2)
_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)
await _warte(func(): return game.phase == "bid", 40.0)
var me: int = game.local_seat
var hud: Node = game.hud
_pruef("Stufe aus der Konfiguration", DiceAI.stufe == 2)
_pruef("Jeder startet mit einer Münze", game.hand[me].size() == 1 and game.hand[1].size() == 1)
_menschen()
# --- Zugbindung
game.hand[1] = ["peek"]
await game._do_play(1, "peek", 0)
_pruef("Gegner wirft nicht am fremden Zug", game.hand[1] == ["peek"] and game.topf.is_empty())
game.hand[me] = ["count"]
await game._do_play(me, "count", 0)
_pruef("Zählen ohne Ansage abgelehnt", game.hand[me].has("count"))
# --- Wirkungen beider Seiten
game.bid = {"seat": 1, "count": 1, "face": 4}
var n4: int = game._count_face(4)
await game._wirkung(me, "count", 0, false)
var f: Array = game.gezaehlt[me][4]
_pruef("Zählen: Fenster enthält die Wahrheit", int(f[0]) <= n4 and n4 <= int(f[1]) and int(f[1]) - int(f[0]) == 2)
await game._wirkung(me, "count", 0, true)
f = game.gezaehlt[me][4]
_pruef("Zählen: Wappen ist exakt", int(f[0]) == n4 and int(f[1]) == n4)
await game._wirkung(me, "set_face", 0, true)
_pruef("Falschspiel dreht zwei Würfel auf die Ansage", game.host_values[me].count(4) >= 2)
await game._wirkung(me, "gain_die", 0, true)
_pruef("Nachschub mit Wappen: neuer Würfel ist 6", game.host_values[me].size() == 6 and game.host_values[me][5] == 6)
await game._wirkung(me, "bid_up", 0, true)
_pruef("Erhöhen mit Wappen: +2", int(game.bid.count) == 3 and game.verlauf.back().erhoeht)
await game._wirkung(me, "gag", 0, false)
_pruef("Maulkorb trifft den Nächsten", game.must_bid.get(1, false) and not game.must_bid.get(2, false))
await game._wirkung(me, "peek", 0, true)
_pruef("Spähen mit Wappen: zwei je Gegner", game.peeked[me][1].size() == 2)
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()
# --- Protokoll und Tipp-Schranke im HUD
game._push_state()
await get_tree().create_timer(0.3).timeout
_pruef("Protokoll kommt im HUD an", hud.log_box.get_child_count() > 0)
hud._touch = true
var geworfen := []
hud.booster_chosen.connect(func(id, _t): geworfen.append(id))
game.hand[me] = ["peek"]
game._push_state()
await get_tree().create_timer(0.3).timeout
hud._pick("peek")
_pruef("Handy: erster Tipp wirft nicht", geworfen.is_empty())
hud._pick("peek")
_pruef("Handy: zweiter Tipp wirft", geworfen == ["peek"])
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()

6
tests/logik.tscn Normal file
View File

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

130
tests/mp_client.gd Normal file
View File

@@ -0,0 +1,130 @@
extends "res://tests/basis.gd"
## Ein Client. "-- host" macht den Raum auf, "-- join" tritt bei und steigt
## in Runde zwei einmal aus und wieder ein. Beide spielen über die echten
## RPC-Wege. Der Raumcode wandert über eine Datei im Scratch-Verzeichnis.
var game: Node
var rolle := ""
var runden := 0
var zuege := 0
var wuerfe := 0
var genau := 0
var wieder_da := false
var _letzte_phase := ""
var _rng := RandomNumberGenerator.new()
var _code_datei := ""
func _ready() -> void:
Engine.time_scale = 8.0
_rng.randomize()
var args := OS.get_cmdline_user_args()
rolle = args[0]
_code_datei = args[1]
await get_tree().create_timer(0.5).timeout
_lauf()
func _lauf() -> void:
if rolle == "host":
Net.create_room("Host")
await _warte(func(): return Net.room_code != "", 40.0)
var f := FileAccess.open(_code_datei, FileAccess.WRITE)
f.store_string(Net.room_code)
f.close()
await _warte(func(): return Net.lobby.size() >= 2, 80.0)
Net.request_start(1, 1)
else:
await _beitreten()
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 120.0)
if game == null:
_pruef("%s: Spiel kommt an" % rolle, false)
_fertig()
return
var erste := await _warte(func(): return game.phase == "bid" or game.phase == "roll", 60.0)
_pruef("%s: erster Zustand kommt an (Platz %d)" % [rolle, game.local_seat], erste)
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
if not is_instance_valid(game):
break
if game.phase != _letzte_phase:
_letzte_phase = game.phase
if game.phase == "roll":
runden += 1
# Der Gast geht in Runde zwei einmal raus und kommt zurück.
if rolle == "join" and not wieder_da and runden >= 2 and game.phase == "bid":
await _wiederkommen()
continue
var me: int = game.local_seat
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:
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
if not is_instance_valid(game) or game.turn != me or game.phase != "bid":
continue
var total := 0
for s in game.seats:
total += int(s.count)
var a: Dictionary = DiceAI.choose_action(game.my_values, total, game.bid, false, _rng)
if a.type == "challenge" and not game.bid.is_empty() and _rng.randf() < 0.3:
a = {"type": "exact"}
match a.type:
"bid":
game._on_bid_pressed(int(a.count), int(a.face))
"challenge":
game._on_challenge_pressed()
"exact":
game._on_exact_pressed()
genau += 1
zuege += 1
gewartet = -2.0
_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)
if is_instance_valid(game):
print("%s: '%s'" % [rolle, game.banner])
for i in game.seats.size():
print("%s: Platz %d %s count=%d alive=%s" % [rolle, i, game.seats[i].name, game.seats[i].count, game.seats[i].alive])
_fertig()
func _beitreten() -> void:
await _warte(func(): return FileAccess.file_exists(_code_datei), 80.0)
await get_tree().create_timer(0.5).timeout
Net.join_room(FileAccess.get_file_as_string(_code_datei).strip_edges(), "Gast")
## Verbindung kappen, frisches Menü, mit demselben Namen wieder beitreten.
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
game = null
await _beitreten()
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)
wieder_da = true
return
var zustand := await _warte(func(): return game.phase == "bid" or game.phase == "roll" or game.phase == "reveal", 60.0)
_pruef("join: nach Rückkehr kommt der Zustand", zustand)
_pruef("join: alter Platz zurück, kein Bot mehr",
game.local_seat == alter_platz and not game.seats[game.local_seat].ai)
_pruef("join: HUD steht wieder", game.hud != null and game.hud.hand_box != null)
_letzte_phase = ""
wieder_da = true
zuege = alte_zuege

32
tests/mp_server.gd Normal file
View File

@@ -0,0 +1,32 @@
extends "res://tests/basis.gd"
## Spielserver im Zeitraffer. Meldet Ende und Endstand, wartet dann noch,
## damit die Clients ihr Ende selbst sehen.
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
if game == null:
game = _find(get_tree().root, "Game")
continue
if not is_instance_valid(game) or game.phase == "over":
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
get_tree().quit()

17
tests/mp_start.gd Normal file
View File

@@ -0,0 +1,17 @@
extends Node
## Startszene für Server und Clients. Hängt den Läufer unter root und lädt
## dann die echte Hauptszene - so heißt der Spielknoten auf allen Seiten
## /root/Main/Raeume/<CODE>/Container/Viewport/Game. Daran hängt jeder RPC:
## ein Test, der die Hauptszene unter eine anders benannte Wurzel hängt,
## bekommt auf der Gegenseite nur "Requested node was not found".
func _ready() -> void:
var args := OS.get_cmdline_user_args()
var r := Node.new()
r.name = "Laeufer"
# Erst zur Laufzeit laden: ein Fehler im einen Skript soll das andere
# nicht mitreissen.
r.set_script(load("res://tests/mp_server.gd" if args.size() > 0 and args[0] == "server"
else "res://tests/mp_client.gd"))
get_tree().root.add_child.call_deferred(r)
get_tree().change_scene_to_file.call_deferred("res://scenes/main.tscn")

6
tests/mp_start.tscn Normal file
View File

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

9
tests/nochmal.gd Normal file
View File

@@ -0,0 +1,9 @@
extends Node
## "Nochmal" wechselt die Szene. Der eigentliche Läufer hängt deshalb direkt
## unter root und überlebt den Wechsel.
func _ready() -> void:
var r := Node.new()
r.name = "Laeufer"
r.set_script(preload("res://tests/nochmal_laeufer.gd"))
get_tree().root.add_child.call_deferred(r)

6
tests/nochmal.tscn Normal file
View File

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

22
tests/nochmal_laeufer.gd Normal file
View File

@@ -0,0 +1,22 @@
extends "res://tests/basis.gd"
var game: Node
var neu: Node
func _ready() -> void:
get_tree().change_scene_to_file("res://scenes/main.tscn")
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)
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()
await _warte(func(): neu = _find(get_tree().root, "Game"); return neu != null and is_instance_valid(neu) and neu != game, 15.0)
_pruef("Nochmal startet eine neue Partie", neu != null)
if neu != null:
await _warte(func(): return neu.phase == "bid", 40.0)
_pruef("Gleiche Bots, gleiche Stufe, Wunsch gelöscht",
neu.seats.size() == 3 and DiceAI.stufe == 0 and Net.rematch.is_empty())
_pruef("Kein Endbildschirm", not neu.hud.over_slot.visible)
_fertig()

51
tests/run.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/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.
set -u
GODOT="${GODOT:-$HOME/bin/godot}"
PORT="${PORT:-8765}"
TMP="$(mktemp -d)"
cd "$(dirname "$0")/.."
ergebnis=0
lauf() { # name, timeout, argumente...
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
}
lauf logik 200
lauf nochmal 120
lauf bots 900 2
echo "=== mehrspieler"
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=$!
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
echo
[ $ergebnis -eq 0 ] && echo "ALLE TESTS GRÜN" || echo "TESTS ROT (Logs: $TMP)"
exit $ergebnis