164 lines
6.0 KiB
Ruby
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
|