Files
CosmicClash/Game/tests/cases/test_matchmaking_state.gd
T

193 lines
14 KiB
GDScript

extends "res://tests/test_case.gd"
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-1", "ranked"), "valid queue starts in QUEUED")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "next revision applies")
assert_eq(state.phase, MatchmakingState.PROPOSED, "proposal is visible")
assert_true(state.can_cancel(), "authoritative cancel remains available before allocation")
func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "proposal phase applies")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 2, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync")
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
func test_ticket_projection_accepts_post_match_lifecycle_states() -> void:
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
var revision := 1
for next_phase in ["PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": revision, "state": next_phase, "playlist": "casual"}), "lifecycle phase applies: " + next_phase)
revision += 1
if next_phase == phase:
break
assert_eq(state.phase, phase, "post-match phase remains visible: " + phase)
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase)
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds")
assert_eq(state.ticket_id, "ticket-1", "queue setup retains ticket identity")
var resync_ids: Array[String] = [""]
state.resync_required.connect(func(id: String) -> void: resync_ids[0] = id)
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-2", "revision": 1, "state": "PROPOSED"}), "another player's ticket is rejected")
assert_eq(resync_ids[0], "ticket-1", "wrong resource requests recovery for current ticket")
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid update cannot mutate phase")
assert_true(state.needs_resync, "invalid identity is visible to recovery")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 3, "state": "ALLOCATING"}), "revision gap is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "gap cannot skip authoritative state")
func test_duplicate_conflict_and_stale_updates_are_safe() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
var update := {"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "casual", "expires_at_unix": 100}
assert_true(state.apply_ticket_update(update), "first update applies")
assert_true(state.apply_ticket_update(update), "identical duplicate is idempotent")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "QUEUED", "playlist": "casual", "expires_at_unix": 100}), "same-revision conflict requests recovery")
assert_eq(state.phase, MatchmakingState.PROPOSED, "conflicting replay cannot rewind state")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 0, "state": "QUEUED"}), "stale update is ignored")
assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state")
func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-transition", "casual")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "legal transition applies")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "LIVE", "playlist": "casual"}), "higher revision cannot jump phases")
assert_eq(state.phase, MatchmakingState.PROPOSED, "illegal jump cannot mutate phase")
state.needs_resync = false
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "ACCEPTED", "playlist": "casual"}), "legal next transition applies")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 3, "state": "PROPOSED", "playlist": "casual"}), "higher revision cannot rewind after acceptance")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-revision", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected")
func test_ticket_update_rejects_invalid_playlist_without_mutation() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-playlist", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-playlist", "revision": 1, "state": "PROPOSED", "playlist": "admin"}), "unknown playlist is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid playlist cannot change phase")
assert_eq(state.playlist, "casual", "invalid playlist cannot change playlist")
func test_ticket_and_proposal_epoch_metadata_rejects_malformed_values() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-epoch", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "expires_at_unix": "not-a-time"}), "malformed ticket expiry is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "malformed ticket expiry cannot change phase")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "enqueued_at_unix": -1}), "negative enqueue time is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-epoch", "revision": 1, "state": "OPEN", "expires_at_unix": 1.25}), "fractional proposal expiry is rejected")
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies")
assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies")
assert_eq(state.phase, MatchmakingState.QUEUED, "decline returns the requeued ticket to search")
assert_true(state.can_cancel(), "requeued ticket can be cancelled")
var expired := MatchmakingState.new()
expired.begin_queue("ticket-2", "casual")
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies")
assert_eq(expired.phase, MatchmakingState.QUEUED, "expiry returns the requeued ticket to search")
assert_true(expired.can_cancel(), "requeued ticket can be cancelled")
var cancelled := MatchmakingState.new()
cancelled.begin_queue("ticket-3", "casual")
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 1, "state": "OPEN"}), "third proposal opens")
cancelled.phase = MatchmakingState.CANCELLED
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 2, "state": "DECLINED"}), "decline after ticket cancellation is accepted")
assert_eq(cancelled.phase, MatchmakingState.CANCELLED, "proposal decline cannot resurrect a cancelled ticket")
func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-proposal-transition", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 2, "state": "ACCEPTED"}), "proposal accepts")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 3, "state": "OPEN"}), "accepted proposal cannot reopen")
assert_eq(state.proposal_state, "ACCEPTED", "illegal proposal transition cannot mutate state")
state.needs_resync = false
var declined := MatchmakingState.new()
declined.begin_queue("ticket-proposal-declined", "casual")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 2, "state": "DECLINED"}), "second proposal declines")
assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept")
func test_terminal_proposal_is_not_an_active_recovery_target() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-terminal-proposal", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.has_open_proposal(), "open proposal is an active recovery target")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 2, "state": "EXPIRED"}), "proposal expires")
assert_true(not state.has_open_proposal(), "terminal proposal uses ticket recovery instead")
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "ranked")
state.mark_assignment_ready()
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "assignment readiness is visible")
state.mark_connecting()
assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible")
state.mark_live()
assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible")
func test_expiry_is_distinct_from_generic_failure_and_remains_visible() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
state.expire("Queue ticket expired")
assert_eq(state.phase, MatchmakingState.EXPIRED, "expired ticket has a terminal expiry state")
assert_eq(state.message, "Queue ticket expired", "expiry reason is visible")
assert_true(not state.can_cancel(), "expired ticket cannot be cancelled")
func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void:
var state := MatchmakingState.new()
assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket_1234567890", "playlist": "casual", "revision": 2}), "valid active snapshot restores")
assert_true(state.needs_resync, "restored active state must recover from the server")
assert_eq(state.revision, 2, "revision is retained for diagnostics")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state")
assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": "2"}), "string revision is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "enqueued_at_unix": 1.5}), "fractional epoch is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "proposal_state": "OPEN"}), "proposal state without identity is rejected")
func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket_wait_123456", "casual"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket_wait_123456", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time")
var restored := MatchmakingState.new()
assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores")
assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore")
assert_true(not restored.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-short", "playlist": "casual"}), "short snapshot ticket id is rejected")
assert_true(not restored.restore_snapshot({"phase": "PROPOSED", "ticket_id": "ticket_wait_123456", "playlist": "casual", "proposal_id": "proposal/unsafe", "proposal_state": "OPEN"}), "unsafe snapshot proposal id is rejected")