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>
196 lines
10 KiB
GDScript
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()
|