diff --git a/Makefile b/Makefile index 0cee640..97a3745 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev server prod logs down +.PHONY: dev server prod logs down test GODOT ?= $(HOME)/bin/godot @@ -12,6 +12,10 @@ server: dev: 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 ─────────────────────────────────────────────────────────────── # Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst. prod: diff --git a/README.md b/README.md index b75d753..52cda2b 100644 --- a/README.md +++ b/README.md @@ -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 `rpc_id` an die Peers des eigenen Raums. * 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. Nur er stellt die Bots ein und startet. * 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 der Spielerliste und eine Zeile über der Ansageleiste. * Ü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 keine Tondateien, so wie es auch keine Bilddateien gibt. * 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/hud.gd` | Spiel-UI | | `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien | +| `tests/` | Headless-Tests, `make test` - 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/game.gd b/scripts/game.gd index 3a5ff2b..100455a 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -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 _klang_phase := "" ## letzte Phase, für die ein Klang lief 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 log_lines: Array = [] var phase := "lobby" @@ -56,13 +57,20 @@ func _ready() -> void: func setup(cfg: Dictionary) -> void: DiceAI.stufe = int(cfg.get("stufe", 1)) seats.clear() - 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}) - for i in int(cfg.ai): - seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true, - "count": START_DICE, "alive": true}) + if cfg.has("seats"): + # Rückkehrer: die laufende Aufstellung, Platz für Platz, in der + # Reihenfolge des Servers - daran hängen alle Indizes im Zustand. + for s in cfg.seats: + 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 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. 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 # ersten Zustand, sonst läge dort kurz eine andere. 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_cup_down(i, i != local_seat) host_values.append([]) @@ -110,10 +118,44 @@ func _on_peer_lost(peer: int) -> void: return seats[seat].ai = true seats[seat].peer = 0 + seats[seat].verwaist = true _log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name) _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: for i in seats.size(): var s = seats[i] @@ -214,6 +256,7 @@ func _phase_roll() -> void: gezaehlt.clear() verlauf.clear() must_bid.clear() + _topf_ziel = -1 banner = "" _log("--- Neue Runde ---") @@ -670,6 +713,7 @@ func _topf_an(winner: int) -> void: 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 @@ -832,6 +876,8 @@ func _state_for(seat: int) -> Dictionary: "actual": reveal_total if phase == "reveal" else 0, "bids": _seat_bids(), "verlauf": verlauf.duplicate(true), + "topf_an": _topf_ziel, + "log": log_lines.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_dice_visible(local_seat, true) _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: _gezaehlt_gezeigt = true if not bid.is_empty(): diff --git a/scripts/hud.gd b/scripts/hud.gd index ca0af51..63da134 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -61,8 +61,13 @@ var info_name: Label var info_desc: Label var info_stark: Label var wurf_slot: CenterContainer -var wurf_icon: MuenzIcon 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_boost: Label var target_row: HBoxContainer @@ -456,21 +461,25 @@ func _build() -> void: hand_panel.visible = false # --- Booster-Overlay - # --- Geworfene Münze, links unter der Liste der gelegten Münzen. Die - # Tischmitte ist besetzt: dort stehen die Becher und das Gebot. - wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 300) + # --- Wurfansage, links unter dem Topf. Die Münze selbst fällt in 3D auf + # den Tisch; hier steht nur, wer was geworfen hat und wie es fiel. + wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 200) 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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER 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 # 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_mitte(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. 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))) -## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Drehung -## ist nur die Ansicht dazu und endet auf der richtigen Seite. +## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Münze +## 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: var b: Dictionary = BoosterDB.get_booster(id) if b.is_empty(): return - wurf_icon.id = id - wurf_icon.kind_col = _kind_col(b) - wurf_icon.seite = 0 - 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_label.text = "%s wirft %s\n%s" % [wer, b.name, + "Wappen - " + String(b.get("stark", "")) if stark else "Zahl - Grundwirkung"] + wurf_label.add_theme_color_override("font_color", ACCENT if stark else DIM) wurf_slot.visible = true Klang.muenze() if _wurf_tween != null and _wurf_tween.is_valid(): _wurf_tween.kill() _wurf_tween = create_tween() - _wurf_tween.tween_method(_dreh, 0.0, TAU * 2.5, 0.8) - _wurf_tween.tween_callback(_wurf_ende.bind(stark, b)) - _wurf_tween.tween_interval(1.6) + _wurf_tween.tween_interval(2.6) _wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false) -## Die Münze wird waagerecht gestaucht; bei negativer Breite sieht man die -## Rückseite. Das ist eine Drehung, ohne dass etwas in 3D stehen muss. -func _dreh(winkel: float) -> void: - var c := cos(winkel) - wurf_icon.scale.x = maxf(0.06, absf(c)) - var seite := 0 if c >= 0.0 else 1 - if wurf_icon.seite != seite: - wurf_icon.seite = seite - wurf_icon.queue_redraw() +## Die letzten Zeilen des Spielprotokolls. +func _zeichne_log(st: Dictionary) -> void: + var zeilen: Array = st.get("log", []) + var sig := str(zeilen) + if sig == _log_sig: + return + _log_sig = sig + _clear(log_box) + 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: + # 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(): booster_chosen.emit(id, 0) boost_slot.visible = false diff --git a/scripts/main.gd b/scripts/main.gd index b088671..e7de75d 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -19,6 +19,7 @@ func _ready() -> void: Net.game_started.connect(_raum_bauen) Net.room_closed.connect(_raum_abbauen) + Net.rejoin_requested.connect(_wiedereinstieg) Net.server_closed.connect(_back_to_menu) var args := OS.get_cmdline_user_args() @@ -88,6 +89,20 @@ func _raum_bauen(code: String, cfg: Dictionary) -> void: 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: var raum := raeume.get_node_or_null(NodePath(code)) if raum != null: diff --git a/scripts/net.gd b/scripts/net.gd index 864048a..c44526c 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -13,6 +13,9 @@ extends Node signal lobby_changed signal game_started(code: String, cfg: Dictionary) 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 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) return 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 if raum.lobby.size() >= MAX_HUMANS: _abgewiesen.rpc_id(id, "Der Raum ist voll.") @@ -182,6 +187,23 @@ func _req_join(code: String, pname: String) -> void: _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. func _lobby_senden(code: String) -> void: var raum: Dictionary = raeume.get(code, {}) diff --git a/scripts/table.gd b/scripts/table.gd index f360a2b..ee475d5 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -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 -## 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 -## Häufchen sehen. -func set_topf(liste: Array) -> void: +## Häufchen sehen. Wappen-Münzen tragen einen geprägten Ring und leuchten +## stärker: so sieht man den Wurfausgang auf dem Tisch, nicht nur im Text. +func set_topf(liste: Array, ziel := -1) -> void: var bleibt := {} for m in liste: var nr := int(m.get("nr", 0)) @@ -490,21 +492,43 @@ func set_topf(liste: Array) -> void: r.seed = nr * 7919 var ang := r.randf() * TAU 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() mi.mesh = _muenz_form() - mi.material_override = _muenz_stoff() - mi.position = ziel + Vector3(0, 0.35, 0) + mi.material_override = _muenz_stoff(wappen) + mi.position = lage + Vector3(0, 0.45, 0) mi.rotation.y = r.randf() * TAU 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 - # Fällt aus der Hand auf den Tisch. - create_tween().tween_property(mi, "position", ziel, 0.35) \ - .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + # Fällt drehend aus der Hand und bleibt flach liegen. + var tw := create_tween().set_parallel(true) + 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(): - if not bleibt.has(nr): - topf_muenzen[nr].queue_free() - topf_muenzen.erase(nr) + if bleibt.has(nr): + continue + 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: @@ -519,7 +543,7 @@ static func _muenz_form() -> CylinderMesh: return _muenz_mesh -static func _muenz_stoff() -> StandardMaterial3D: +static func _muenz_stoff(wappen: bool) -> StandardMaterial3D: if _muenz_mat == null: _muenz_mat = StandardMaterial3D.new() _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 = Color(0.55, 0.40, 0.12) _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 diff --git a/test_cli.gd b/test_cli.gd deleted file mode 100644 index ebdaf4c..0000000 --- a/test_cli.gd +++ /dev/null @@ -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() diff --git a/test_cli.tscn b/test_cli.tscn deleted file mode 100644 index 7d870b3..0000000 --- a/test_cli.tscn +++ /dev/null @@ -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") diff --git a/test_srv.gd b/test_srv.gd deleted file mode 100644 index 0f847c4..0000000 --- a/test_srv.gd +++ /dev/null @@ -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() diff --git a/test_srv.tscn b/test_srv.tscn deleted file mode 100644 index 5fac189..0000000 --- a/test_srv.tscn +++ /dev/null @@ -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") diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ca871e0 --- /dev/null +++ b/tests/README.md @@ -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//…`). 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. diff --git a/tests/basis.gd b/tests/basis.gd new file mode 100644 index 0000000..bd7515b --- /dev/null +++ b/tests/basis.gd @@ -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) diff --git a/tests/bots.gd b/tests/bots.gd new file mode 100644 index 0000000..c3d9341 --- /dev/null +++ b/tests/bots.gd @@ -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() diff --git a/tests/bots.tscn b/tests/bots.tscn new file mode 100644 index 0000000..290f6a7 --- /dev/null +++ b/tests/bots.tscn @@ -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") diff --git a/tests/logik.gd b/tests/logik.gd new file mode 100644 index 0000000..9374134 --- /dev/null +++ b/tests/logik.gd @@ -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() diff --git a/tests/logik.tscn b/tests/logik.tscn new file mode 100644 index 0000000..008b8df --- /dev/null +++ b/tests/logik.tscn @@ -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") diff --git a/tests/mp_client.gd b/tests/mp_client.gd new file mode 100644 index 0000000..f8ac822 --- /dev/null +++ b/tests/mp_client.gd @@ -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 diff --git a/tests/mp_server.gd b/tests/mp_server.gd new file mode 100644 index 0000000..eb48b77 --- /dev/null +++ b/tests/mp_server.gd @@ -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() diff --git a/tests/mp_start.gd b/tests/mp_start.gd new file mode 100644 index 0000000..00979c5 --- /dev/null +++ b/tests/mp_start.gd @@ -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//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") diff --git a/tests/mp_start.tscn b/tests/mp_start.tscn new file mode 100644 index 0000000..f6c08b3 --- /dev/null +++ b/tests/mp_start.tscn @@ -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") diff --git a/tests/nochmal.gd b/tests/nochmal.gd new file mode 100644 index 0000000..e3071d1 --- /dev/null +++ b/tests/nochmal.gd @@ -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) diff --git a/tests/nochmal.tscn b/tests/nochmal.tscn new file mode 100644 index 0000000..be207af --- /dev/null +++ b/tests/nochmal.tscn @@ -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") diff --git a/tests/nochmal_laeufer.gd b/tests/nochmal_laeufer.gd new file mode 100644 index 0000000..ed118ce --- /dev/null +++ b/tests/nochmal_laeufer.gd @@ -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() diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..29674a6 --- /dev/null +++ b/tests/run.sh @@ -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