Files

164 lines
6.0 KiB
Ruby

require "rails_helper"
RSpec.describe MatchmakingService do
# The service uses the shared RedisConnection (test env points at db 15).
def redis
RedisConnection.with { |r| return r }
end
# Unique user ids across the whole spec run (Action Cable test-adapter
# broadcasts persist between examples; example-group instance variables
# are reset per example, so a global counter is used).
def uid(base)
$matchmaking_spec_uid = ($matchmaking_spec_uid || 0) + 1
$matchmaking_spec_uid * 1000 + base
end
def broadcasts_for(user_id)
channel = MatchmakingChannel.broadcasting_for(user_id)
ActionCable.server.pubsub.broadcasts(channel)
end
def broadcast_payloads(user_id)
broadcasts_for(user_id).map { |m| JSON.parse(m, symbolize_names: true) }
end
before { redis.flushdb }
describe ".enqueue" do
it "does not match players whose ratings are outside the tolerance" do
a, b = uid(1), uid(2)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(b, rating: 5000)
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to contain_exactly(a.to_s, b.to_s)
expect(broadcasts_for(a)).to be_empty
expect(broadcasts_for(b)).to be_empty
end
it "matches two compatible players immediately, nearest rating first" do
a, b, c = uid(1), uid(2), uid(3)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(b, rating: 1400) # outside base range (100)
described_class.enqueue(c, rating: 1050) # nearest to a
match = broadcast_payloads(a).first
expect(match[:type]).to eq("matched")
expect(match[:players]).to contain_exactly(a, c)
expect(match[:match_id]).to be_present
expect(match[:game_token]).to be_present
expect(match[:game_server]).to eq(Rails.application.config.x.matchmaking.game_server_url)
expect(broadcasts_for(c)).not_to be_empty
# b stays in the queue
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to eq([ b.to_s ])
end
it "restarts the waiting session for a user already in the queue" do
a = uid(1)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(a, rating: 1200)
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to eq([ a.to_s ])
expect(redis.zscore(described_class::QUEUE_KEY, a.to_s)).to eq(1200)
end
it "delivers the match exactly once (broadcast consumes the pointer)" do
a, b = uid(1), uid(2)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(b, rating: 1000)
expect(broadcasts_for(a).size).to eq(1)
expect(described_class.claim(a)).to be_nil
end
end
describe ".sweep" do
it "widens the tolerance over waiting time and pairs players" do
a, b = uid(1), uid(2)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(b, rating: 1400)
# backdate b's enqueue time (in ms, as the script stores it) so its
# tolerance has widened enough
redis.hset(described_class::SINCE_KEY, b.to_s, (10.seconds.ago.to_f * 1000).to_i)
described_class.sweep
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
expect(broadcasts_for(a)).not_to be_empty
expect(broadcasts_for(b)).not_to be_empty
end
it "expires players who waited longer than the queue TTL" do
a = uid(1)
described_class.enqueue(a, rating: 1000)
redis.hset(described_class::SINCE_KEY, a.to_s,
((described_class.config.queue_ttl + 5).seconds.ago.to_f * 1000).to_i)
described_class.sweep
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
expect(broadcast_payloads(a).first[:type]).to eq("timeout")
end
it "requeues players stuck in the pending state without a match" do
a, b = uid(1), uid(2)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(b, rating: 1000)
# simulate a crash between pairing and finalize: back into pending
# (scores are pairing timestamps in milliseconds). The pairing step
# already removed the players from the queue; restore their ratings as
# they would exist before finalize deletes them.
redis.hset(described_class::RATING_KEY, a.to_s, 1000, b.to_s, 1000)
past = (described_class.config.pending_ttl + 5).seconds.ago.to_f * 1000
redis.zadd(described_class::PENDING_KEY, past.to_i, a.to_s)
redis.zadd(described_class::PENDING_KEY, past.to_i, b.to_s)
# pointers must be absent for the requeue to kick in
redis.del("#{described_class::USER_KEY_PREFIX}#{a}", "#{described_class::USER_KEY_PREFIX}#{b}")
described_class.sweep
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to contain_exactly(a.to_s, b.to_s)
end
it "does not requeue pending players whose match was finalized" do
a, b = uid(1), uid(2)
described_class.enqueue(a, rating: 1000)
described_class.enqueue(b, rating: 1000)
# emulate a leftover stale pending entry for a user with a live pointer
past = (described_class.config.pending_ttl + 5).seconds.ago.to_f * 1000
redis.zadd(described_class::PENDING_KEY, past.to_i, a.to_s)
described_class.sweep
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
expect(broadcasts_for(a)).not_to be_empty
end
end
describe ".cancel" do
it "removes the player from the queue" do
a = uid(1)
described_class.enqueue(a, rating: 1000)
described_class.cancel(a)
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
expect(redis.hget(described_class::SINCE_KEY, a.to_s)).to be_nil
end
end
describe ".claim" do
it "returns the match payload exactly once" do
a = uid(1)
payload = { type: "matched", match_id: "m-1", players: [ a, a + 1 ] }.to_json
redis.set("#{described_class::USER_KEY_PREFIX}#{a}", payload)
first = described_class.claim(a)
expect(first[:type]).to eq("matched")
expect(described_class.claim(a)).to be_nil
end
end
end