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

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