mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(server): complete phase 6 local verification
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Task 6.5. "The server cycles arenas" has to be an assertable claim rather
|
||||
# than an observation about luck, which is why sequential rotation is a pure
|
||||
# function of the completed-match count.
|
||||
|
||||
|
||||
func test_sequential_rotation_visits_every_arena_before_repeating() -> void:
|
||||
var paths := ArenaRegistry.rotation_paths()
|
||||
assert_true(paths.size() >= 2, "rotation needs at least two arenas to mean anything")
|
||||
var seen := {}
|
||||
for i in paths.size():
|
||||
seen[ArenaRegistry.path_for_match(i, "sequential")] = true
|
||||
assert_eq(seen.size(), paths.size(), "every rotation arena appears in the first cycle")
|
||||
|
||||
|
||||
func test_sequential_rotation_wraps_rather_than_running_out() -> void:
|
||||
var paths := ArenaRegistry.rotation_paths()
|
||||
var first: String = ArenaRegistry.path_for_match(0, "sequential")
|
||||
var wrapped: String = ArenaRegistry.path_for_match(paths.size(), "sequential")
|
||||
assert_eq(wrapped, first, "match N wraps back to the first arena")
|
||||
# And a long-running server must not drift or fault at large counts.
|
||||
assert_eq(ArenaRegistry.path_for_match(paths.size() * 1000, "sequential"), first, "still correct after a thousand cycles")
|
||||
|
||||
|
||||
func test_consecutive_matches_are_never_the_same_arena_in_sequential_mode() -> void:
|
||||
# The point of rotation is that players do not play the same arena twice in
|
||||
# a row; wrapping must not produce a repeat at the seam either.
|
||||
var paths := ArenaRegistry.rotation_paths()
|
||||
for i in paths.size() * 2:
|
||||
var current: String = ArenaRegistry.path_for_match(i, "sequential")
|
||||
var next: String = ArenaRegistry.path_for_match(i + 1, "sequential")
|
||||
assert_true(current != next, "match %d and %d differ" % [i, i + 1])
|
||||
|
||||
|
||||
func test_rotation_never_offers_an_arena_bots_cannot_score_in() -> void:
|
||||
# Elevated-goal variants are Free-Play-only until a checkpoint trained on
|
||||
# them is promoted. A server rotating onto one would hand every bot-filled
|
||||
# slot an arena it cannot score in.
|
||||
var rotation := ArenaRegistry.rotation_paths()
|
||||
for arena in ArenaRegistry.ARENAS:
|
||||
if not arena["random"]:
|
||||
assert_true(not (arena["path"] in rotation), "%s is excluded from rotation" % arena["name"])
|
||||
assert_true(rotation.size() > 0, "and something is left to rotate through")
|
||||
|
||||
|
||||
func test_random_mode_stays_inside_the_rotation_set() -> void:
|
||||
for i in 50:
|
||||
var path: String = ArenaRegistry.path_for_match(i, "random")
|
||||
assert_true(path in ArenaRegistry.rotation_paths(), "random picks are still rotation-eligible")
|
||||
|
||||
|
||||
func test_an_unknown_mode_falls_back_to_sequential_rather_than_faulting() -> void:
|
||||
# The CLI already rejects an undeclared mode, so this is the belt to that's
|
||||
# braces — but a server must not crash between matches over a string.
|
||||
assert_eq(
|
||||
ArenaRegistry.path_for_match(1, "spiral"),
|
||||
ArenaRegistry.path_for_match(1, "sequential"),
|
||||
"an unrecognised mode behaves as sequential"
|
||||
)
|
||||
@@ -100,6 +100,7 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
|
||||
assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected")
|
||||
assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected")
|
||||
assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected")
|
||||
assert_true(not _parse(["--smoke-force-goal-after=-2"]).is_valid(), "only -1 disables the deterministic smoke goal")
|
||||
# Control: the same flags at legal values all pass together.
|
||||
var ok = _parse(["--port=7000", "--max-clients=6", "--match-length=90", "--log-level=warn", "--arena-rotation=random"])
|
||||
assert_true(ok.is_valid(), "control: legal values pass (%s)" % str(ok.errors))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cr0hqi7fwac2e
|
||||
@@ -0,0 +1,97 @@
|
||||
extends Node
|
||||
|
||||
# Task 6.2 / 6.7: black-box client for the exported dedicated-server smoke.
|
||||
# It contains no in-process server hook: two copies run in distinct containers,
|
||||
# join through ENet, and pass only after an authoritative score RPC arrives.
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const TIMEOUT_SECONDS := 55.0
|
||||
|
||||
var _address := "server"
|
||||
var _port := DEFAULT_PORT
|
||||
var _name := "ExportSmoke"
|
||||
var _expected_goals := 1
|
||||
var _goals_observed := 0
|
||||
|
||||
@onready var _network_manager: Node = get_node("/root/NetworkManager")
|
||||
@onready var _match_net: Node = get_node("/root/MatchNet")
|
||||
@onready var _match_sim: Node = get_node("/root/MatchSim")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# The client enters networked_match.tscn after the hello handshake. Keep
|
||||
# this observer outside that scene so the scene transition cannot free it
|
||||
# before the authoritative score RPC arrives. Deferring avoids reparenting
|
||||
# while Godot is still adding the smoke scene to the tree.
|
||||
call_deferred("_move_to_root")
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--address="):
|
||||
_address = arg.get_slice("=", 1)
|
||||
elif arg.begins_with("--port="):
|
||||
_port = int(arg.get_slice("=", 1))
|
||||
elif arg.begins_with("--name="):
|
||||
_name = arg.get_slice("=", 1)
|
||||
elif arg.begins_with("--expected-goals="):
|
||||
_expected_goals = maxi(1, int(arg.get_slice("=", 1)))
|
||||
# Use node paths instead of autoload identifiers: this test deliberately
|
||||
# runs from a clean source tree, before Godot has an editor-generated cache.
|
||||
_match_net.set("local_player_name", _name)
|
||||
_match_net.connect("welcomed", _on_welcomed)
|
||||
_match_sim.connect("score_update_received", _on_score_update)
|
||||
get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout)
|
||||
# ENet's connection state machine handles a server still booting. Joining
|
||||
# immediately also ensures MatchSim never runs a physics tick on an inactive
|
||||
# MultiplayerPeer while a timer waits to make the first connection attempt.
|
||||
_connect()
|
||||
|
||||
|
||||
func _move_to_root() -> void:
|
||||
var parent := get_parent()
|
||||
if parent == null:
|
||||
return
|
||||
var tree := get_tree()
|
||||
parent.remove_child(self)
|
||||
tree.root.add_child(self)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_network_manager.call("poll")
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_network_manager.call("poll")
|
||||
|
||||
|
||||
func _connect() -> void:
|
||||
var err: int = _network_manager.call("join", _address, _port)
|
||||
if err != OK:
|
||||
_fail("join(%s:%d) failed: %s" % [_address, _port, error_string(err)])
|
||||
|
||||
|
||||
func _on_welcomed() -> void:
|
||||
_match_net.disconnect("welcomed", _on_welcomed)
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)
|
||||
|
||||
|
||||
func _on_score_update(score: Dictionary) -> void:
|
||||
if int(score.get(0, 0)) + int(score.get(1, 0)) < 1:
|
||||
return
|
||||
_goals_observed += 1
|
||||
if _goals_observed < _expected_goals:
|
||||
print("EXPORT SMOKE: %s observed match %d/%d score %s" % [_name, _goals_observed, _expected_goals, str(score)])
|
||||
return
|
||||
print("EXPORT SMOKE PASS: %s observed %d authoritative goals" % [_name, _goals_observed])
|
||||
# Give the reliable goal/state messages one beat to settle before this peer
|
||||
# leaves, then let the server's zero-reservation test config abort/drain.
|
||||
get_tree().create_timer(0.5).timeout.connect(func() -> void: get_tree().quit(0))
|
||||
|
||||
|
||||
func _on_timeout() -> void:
|
||||
if _goals_observed < _expected_goals:
|
||||
_fail("%s timed out without an authoritative goal" % _name)
|
||||
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
printerr("EXPORT SMOKE FAIL: " + message)
|
||||
_network_manager.call("shutdown")
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/export_server_smoke.gd" id="1_smoke"]
|
||||
|
||||
[node name="ExportServerSmokeScene" type="Node"]
|
||||
|
||||
[node name="ExportServerSmoke" type="Node" parent="."]
|
||||
script = ExtResource("1_smoke")
|
||||
Reference in New Issue
Block a user