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()