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_authoritative_ticket_snapshot_can_cross_missed_forward_revisions_but_not_rewind() -> void: var state := MatchmakingState.new() state.begin_queue("ticket-snapshot", "casual") assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "incremental proposal applies") assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 5, "state": "ASSIGNMENT_READY", "playlist": "casual"}, true), "owner-scoped REST snapshot crosses missed forward states") assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "authoritative recovery reaches assignment readiness") state.needs_resync = false assert_true(not state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 6, "state": "QUEUED", "playlist": "casual"}, true), "authoritative snapshot cannot rewind an assigned match") assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "rejected snapshot 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") assert_true(state.prepare_proposal_recovery("proposal_second_123"), "a later proposal can replace a terminal proposal identity") assert_true(state.apply_proposal_update({"proposal_id": "proposal_second_123", "revision": 4, "state": "OPEN"}), "recovered later proposal accepts its authoritative revision") assert_true(state.has_open_proposal(), "later proposal becomes the active recovery target") assert_true(not state.prepare_proposal_recovery("proposal_third_1234"), "an open proposal cannot be replaced by another identity") 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")