saumwacht-godot/tests/luneraria/test_lune_net.gd
Martin Stickelbroeck c15533831a M4: Spiel läuft vollständig über die autoritative Sitzung; M4 pausiert
Das echte Spiel umging die Sitzung bisher bei Bereitschaft, Startaufstellung
und Rückgängig, ließ Bots den Gast-Sitz planen und den Gast selbst simulieren.
Jetzt rechnet nur der Host; Gäste zeigen den Kampf aus Momentaufnahmen.
Dazu Protokollversion, Wiedereinstieg über den Anzeigenamen, verbindliches
Bereit, Entwurfs-Privatsphäre und Bot-Planung einmal pro Runde.

Geprüft: test_lune_net 43/43, net_rounds.py 12/12 (5 Runden, Wiedereinstieg,
gleiche Prüfsummen), Regeln 91/91, M2 73/73, verify_m1 38/38, verify_m2 14/14.
Offen und eingefroren: OpenGL-Modus, nativer Windows-11-Test, Demo-Neubau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:00:08 +02:00

196 lines
10 KiB
GDScript

extends Node
## Network protocol tests for the authoritative LuneSession (no rendering).
## Covers foreign seats, stale commands, double purchase, bot takeover on disconnect
## and the rule that a disconnect is never a core destruction.
## Run: godot --headless --path . res://tests/luneraria/test_lune_net.tscn
var checks: int = 0
var failures: Array[String] = []
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("NET_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func new_host(seed: int = 5) -> LuneSession:
var session: LuneSession = LuneSession.new()
session.name = "LuneSession"
add_child(session)
session.host_offline(0, {"seed": seed, "human_slot": 0,
"bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]})
return session
func run() -> void:
_test_seat_ownership()
_test_stale_command()
_test_double_purchase()
_test_foreign_uid()
_test_disconnect_is_not_core_loss()
_test_six_seats_and_phases()
_test_hello_and_rejoin()
_test_takeover_and_bot_once()
_test_ready_commits_and_privacy()
var report := {"checks": checks, "failures": failures}
var file := FileAccess.open("res://docs/luneraria/logs/net-tests.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("LUNE_NET_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()])
get_tree().quit(0 if failures.is_empty() else 1)
func _test_seat_ownership() -> void:
var session: LuneSession = new_host()
session.seat_peer(1, 42, "Gast")
check(session.seat_of_peer(42) == 1, "peer is seated on slot 1")
# Peer 42 tries to act as slot 3.
var spoofed: Dictionary = session.host_command(42, 3, {"type": "buy", "type_id": "w01"})
check(not spoofed.ok, "command from another seat is rejected")
# Unseated peer.
var orphan: Dictionary = session.host_command(99, 0, {"type": "buy", "type_id": "w01"})
check(not orphan.ok, "command from an unseated peer is rejected")
# The owner may act during planning (bots are pre-ready, so do not ready up yet).
var own: Dictionary = session.host_command(1, 0, {"type": "buy", "type_id": session.match_state.base_ids("glutpakt")[0]})
check(own.ok, "the seat owner may buy")
session.close()
func _test_stale_command() -> void:
var session: LuneSession = new_host()
session.host_ready(0)
for slot: int in range(1, 6):
session.host_ready(slot)
check(session.match_state.phase == LuneMatch.Phase.COMBAT, "all ready starts combat")
var late: Dictionary = session.host_command(1, 0, {"type": "buy", "type_id": "w01"})
check(not late.ok and "spät" in str(late.message) or "vorbei" in str(late.message), "stale planning command after the phase closed is rejected")
session.close()
func _test_double_purchase() -> void:
var session: LuneSession = new_host()
var ids: Array[String] = session.match_state.base_ids("glutpakt")
var cost: int = int(DataCatalog.get_unit(ids[0]).cost)
session.match_state.players[0].draft.gold = 1000
var first: Dictionary = session.host_command(1, 0, {"type": "buy", "type_id": ids[0]})
var gold_after_first: int = int(session.match_state.players[0].draft.gold)
var roster_after_first: int = int(session.match_state.players[0].draft.roster.size())
check(first.ok and roster_after_first == 1 and gold_after_first == 1000 - cost, "one purchase spends the catalogue cost exactly once")
# A duplicated packet is another buy command: it may buy a second unit at a cost,
# but it must never hand out a free clone (ownership is the uid list plus gold).
session.host_command(1, 0, {"type": "buy", "type_id": ids[0]})
var roster_after: int = int(session.match_state.players[0].draft.roster.size())
var gold_after: int = int(session.match_state.players[0].draft.gold)
check(roster_after == roster_after_first + 1, "a repeated buy creates one more unit, not a free clone")
check(gold_after == gold_after_first - cost, "the repeated buy also pays exactly once")
var uids: Dictionary = {}
for entry: Dictionary in session.match_state.players[0].draft.roster:
uids[str(entry.uid)] = true
check(uids.size() == roster_after, "every owned unit has a unique id")
session.close()
func _test_foreign_uid() -> void:
var session: LuneSession = new_host()
session.seat_peer(1, 7, "Gast")
var ids: Array[String] = session.match_state.base_ids("glutpakt")
session.match_state.players[0].draft.gold = 500
session.host_command(1, 0, {"type": "buy", "type_id": ids[0]})
var uid: String = str(session.match_state.players[0].draft.roster[0].uid)
# Guest tries to upgrade the host's unit.
var steal: Dictionary = session.host_command(7, 1, {"type": "upgrade", "uid": uid})
check(not steal.ok, "a guest cannot upgrade a foreign unit id")
# Guest tries to act as the host seat.
var hijack: Dictionary = session.host_command(7, 0, {"type": "upgrade", "uid": uid})
check(not hijack.ok, "a guest cannot act on the host seat")
# Owner may upgrade their own unit.
var ids2: Array[String] = session.match_state.base_ids("glutpakt")
var upgrade_id: String = str(DataCatalog.get_unit(ids[0]).get("upgrade_id", ""))
if not upgrade_id.is_empty():
session.match_state.players[0].draft.gold = 500
var ok: Dictionary = session.host_command(1, 0, {"type": "upgrade", "uid": uid})
check(ok.ok, "the owner may upgrade their own unit")
session.close()
func _test_disconnect_is_not_core_loss() -> void:
var session: LuneSession = new_host()
session.seat_peer(1, 50, "Gast")
session.seat_peer(2, 51, "Gast2")
var core0: float = session.match_state.core_hp(0)
var core1: float = session.match_state.core_hp(1)
session._on_peer_disconnected(50)
check(session.match_state.core_hp(0) == core0 and session.match_state.core_hp(1) == core1, "disconnect does not damage either Ancient")
check(session.match_state.phase != LuneMatch.Phase.OVER, "disconnect does not end the match")
check(session.match_state.winner == LuneMatch.ONGOING, "disconnect is not a core victory")
check(not session.seat_peers.has(1), "the lost seat is no longer peer-held")
var bot_planned: bool = false
for entry: Dictionary in session.lobby:
if int(entry.slot) == 1:
bot_planned = bool(entry.bot) and bool(entry.ready)
check(bot_planned, "the disconnected seat is taken over by a bot")
session.close()
func _test_six_seats_and_phases() -> void:
var session: LuneSession = new_host()
check(session.match_state.players.size() == 6, "six seats exist")
var teams: Dictionary = {}
for player: Dictionary in session.match_state.players:
teams[player.team] = int(teams.get(player.team, 0)) + 1
check(teams.size() == 2 and teams[0] == 3 and teams[1] == 3, "two teams of three")
check(session.match_state.map.buildings.filter(func(d: Dictionary) -> bool: return d.tier != "core").size() == 16, "sixteen towers")
check(session.match_state.map.buildings.filter(func(d: Dictionary) -> bool: return d.tier == "core").size() == 2, "two Ancients")
session.host_ready(0)
for slot: int in range(1, 6):
session.host_ready(slot)
session.plan_bot_seats()
# Bots already planned via host_begin_combat; ensure a full round works offline-style.
if session.match_state.phase == LuneMatch.Phase.PLANNING:
session.host_begin_combat()
check(session.match_state.phase == LuneMatch.Phase.COMBAT, "combat starts when everyone is ready")
session.host_finish_combat()
check(session.match_state.phase == LuneMatch.Phase.PLANNING or session.match_state.phase == LuneMatch.Phase.OVER, "settlement returns to planning or ends")
session.close()
func _test_hello_and_rejoin() -> void:
var session: LuneSession = new_host()
check(session.host_hello(60, LuneSession.PROTOCOL - 1, "Alt") == -1, "an older protocol is refused")
check(session.seat_of_peer(60) < 0, "a refused peer holds no seat")
var anna: int = session.host_hello(61, LuneSession.PROTOCOL, "Anna")
check(anna == 1, "the first guest takes the first free seat")
check(session.host_hello(61, LuneSession.PROTOCOL, "Anna") == anna, "a repeated hello keeps the one seat")
session._on_peer_disconnected(61)
check(bool(session.match_state.players[anna].ready) and not bool(session.match_state.players[anna].is_human), "a departed seat is a ready bot")
var bert: int = session.host_hello(62, LuneSession.PROTOCOL, "Bert")
check(bert >= 0 and bert != anna, "a new guest does not take a seat reserved for rejoining")
var back: int = session.host_hello(63, LuneSession.PROTOCOL, "Anna")
check(back == anna, "a returning guest gets the same seat back by name")
check(bool(session.match_state.players[anna].is_human) and not bool(session.match_state.players[anna].ready), "the returned seat is human and must ready up")
session.close()
func _test_takeover_and_bot_once() -> void:
var session: LuneSession = new_host()
session.plan_bot_seats()
var bot: Dictionary = session.match_state.players[2]
var spent_by_bot: int = int(bot.gold) - int(bot.draft.gold)
check(spent_by_bot > 0, "a bot seat plans and spends gold")
session.plan_bot_seats()
check(int(bot.gold) - int(bot.draft.gold) == spent_by_bot, "a bot seat plans only once per planning phase")
session.seat_peer(2, 70, "Cora")
check(int(bot.draft.gold) == int(bot.gold) and (bot.draft.roster as Array).is_empty(), "a takeover in planning drops the bot's uncommitted draft")
check(not bool(bot.ready), "the new human must ready up")
session.close()
func _test_ready_commits_and_privacy() -> void:
var session: LuneSession = new_host()
session.seat_peer(1, 80, "Dora")
var ids: Array[String] = session.match_state.base_ids("wurzelbund")
check(session.host_command(80, 1, {"type": "buy", "type_id": ids[0]}).ok, "the guest buys during planning")
check(session.host_command(80, 1, {"type": "undo"}).ok, "undo goes through the host")
check((session.match_state.players[1].draft.roster as Array).is_empty(), "undo removed the purchase")
session.host_command(80, 1, {"type": "buy", "type_id": ids[0]})
session.host_ready(1)
check(not session.host_command(80, 1, {"type": "buy", "type_id": ids[0]}).ok, "after ready the plan is binding")
var state: Dictionary = session.state_for(1)
var drafts: Array = (state.players as Array).filter(func(row: Dictionary) -> bool: return row.has("draft"))
check(drafts.size() == 1 and int(drafts[0].slot) == 1, "a seat receives only its own draft")
check(session.match_state.phase == LuneMatch.Phase.PLANNING, "combat waits for the host seat")
session.host_ready(0)
check(session.match_state.phase == LuneMatch.Phase.COMBAT, "combat starts once every human is ready")
session.close()