update
This commit is contained in:
6
Makefile
6
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:
|
||||
|
||||
13
README.md
13
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
|
||||
|
||||
@@ -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()
|
||||
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})
|
||||
"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})
|
||||
"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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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()
|
||||
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
|
||||
|
||||
81
test_cli.gd
81
test_cli.gd
@@ -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()
|
||||
@@ -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")
|
||||
32
test_srv.gd
32
test_srv.gd
@@ -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()
|
||||
@@ -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
25
tests/README.md
Normal 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
35
tests/basis.gd
Normal 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
47
tests/bots.gd
Normal 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
6
tests/bots.tscn
Normal 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
126
tests/logik.gd
Normal 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
6
tests/logik.tscn
Normal 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
130
tests/mp_client.gd
Normal 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
32
tests/mp_server.gd
Normal 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
17
tests/mp_start.gd
Normal 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
6
tests/mp_start.tscn
Normal 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
9
tests/nochmal.gd
Normal 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
6
tests/nochmal.tscn
Normal 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
22
tests/nochmal_laeufer.gd
Normal 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
51
tests/run.sh
Executable 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
|
||||
Reference in New Issue
Block a user