From 7db58e42ab0ad7cd2c400ce7881a79c18a9fd897 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 21 Sep 2026 22:40:35 +0300 Subject: [PATCH] add random match-making logic --- .gitignore | 2 +- main-server/Gemfile | 2 + main-server/Gemfile.lock | 7 + .../controllers/api/matchmaking_controller.rb | 21 +++ .../app/services/matchmaking_service.rb | 122 ++++++++++++++++++ main-server/config/initializers/redis.rb | 7 + main-server/config/routes.rb | 3 + 7 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 main-server/app/controllers/api/matchmaking_controller.rb create mode 100644 main-server/app/services/matchmaking_service.rb create mode 100644 main-server/config/initializers/redis.rb diff --git a/.gitignore b/.gitignore index 7de6e5b..732038b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,5 @@ # Ignore key files for decrypting credentials and more. /main-server/config/*.key -/frontend/node-modules +/frontend/node_modules /frontend/dist diff --git a/main-server/Gemfile b/main-server/Gemfile index f72704b..80c1159 100644 --- a/main-server/Gemfile +++ b/main-server/Gemfile @@ -38,3 +38,5 @@ group :development, :test do gem "brakeman", require: false gem "rubocop-rails-omakase", require: false end + +gem "redis", "~> 6.0" diff --git a/main-server/Gemfile.lock b/main-server/Gemfile.lock index aaa86c7..2089d47 100644 --- a/main-server/Gemfile.lock +++ b/main-server/Gemfile.lock @@ -250,6 +250,10 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort + redis (6.0.0) + redis-client (= 0.30.1) + redis-client (0.30.1) + connection_pool regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) @@ -357,6 +361,7 @@ DEPENDENCIES puma (>= 5.0) rack-cors rails (~> 8.1.2) + redis (~> 6.0) rubocop-rails-omakase solid_cable solid_cache @@ -459,6 +464,8 @@ CHECKSUMS rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + redis (6.0.0) sha256=de71c10edd106986b759ec7ecdd08b63b9c0ee7414a0d0c1da73d31ba2bccda6 + redis-client (0.30.1) sha256=5151bc5c7bbfe48623732cdae3b900d8a22dc691cc7cdfacfb351ac55116522d regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0 diff --git a/main-server/app/controllers/api/matchmaking_controller.rb b/main-server/app/controllers/api/matchmaking_controller.rb new file mode 100644 index 0000000..40cf4de --- /dev/null +++ b/main-server/app/controllers/api/matchmaking_controller.rb @@ -0,0 +1,21 @@ +class Api::MatchmakingController < ApplicationController + def create + result = MatchmakingService.find_match(current_user.id) + + if result + render json: { + match_id: result[:match_id], + game_token: result[:game_token], + game_server: result[:game_server], + players: result[:players] + } + else + render json: { error: "No opponent found. Try again." }, status: :request_timeout + end + end + + def destroy + MatchmakingService.cancel(current_user.id) + render json: { message: "Matchmaking cancelled" } + end +end diff --git a/main-server/app/services/matchmaking_service.rb b/main-server/app/services/matchmaking_service.rb new file mode 100644 index 0000000..30b43d0 --- /dev/null +++ b/main-server/app/services/matchmaking_service.rb @@ -0,0 +1,122 @@ +class MatchmakingService + QUEUE_KEY = "matchmaking:queue" + LOCK_KEY = "matchmaking:lock" + MATCH_PREFIX = "matchmaking:match:" + MATCH_TTL = 60 # seconds + POLL_INTERVAL = 0.5 # seconds + MAX_WAIT = 30 # seconds + + # Game server to redirect players to (hardcoded for now) + GAME_SERVER = ENV.fetch("GAME_SERVER_URL", "ws://game-server.example.com:8080") + + class << self + # Main entry point: waits for an opponent and returns match data + def find_match(user_id) + user_id = user_id.to_i + + # Try to pair with an existing waiting player + opponent_id = pop_opponent(user_id) + + if opponent_id + create_match(user_id, opponent_id) + else + # Add self to queue and wait + add_to_queue(user_id) + wait_for_match(user_id) + end + end + + # Cancel matchmaking (remove from queue) + def cancel(user_id) + RedisConnection.with do |redis| + redis.lrem(QUEUE_KEY, 0, user_id) + end + end + + private + + # Atomically pop an opponent from the queue (excluding self) + def pop_opponent(user_id) + RedisConnection.with do |redis| + redis.watch(QUEUE_KEY) do + queue = redis.lrange(QUEUE_KEY, 0, -1) + opponent = queue.find { |id| id.to_i != user_id } + + if opponent + result = redis.multi do |tx| + tx.lrem(QUEUE_KEY, 0, opponent) + end + return opponent.to_i if result&.first&.positive? + else + redis.unwatch + end + end + end + nil + end + + def add_to_queue(user_id) + RedisConnection.with do |redis| + # Remove any stale entries first + redis.lrem(QUEUE_KEY, 0, user_id) + redis.rpush(QUEUE_KEY, user_id) + end + end + + def wait_for_match(user_id) + start_time = Time.current + match_id = nil + + while Time.current - start_time < MAX_WAIT + RedisConnection.with do |redis| + match_id = redis.get("#{MATCH_PREFIX}#{user_id}") + end + break if match_id + + sleep POLL_INTERVAL + end + + if match_id + # Retrieve and delete the match data + data = nil + RedisConnection.with do |redis| + data = redis.get("#{MATCH_PREFIX}match:#{match_id}") + redis.del("#{MATCH_PREFIX}#{user_id}") + redis.del("#{MATCH_PREFIX}match:#{match_id}") + end + JSON.parse(data, symbolize_names: true) if data + else + # Timeout: remove from queue + cancel(user_id) + nil + end + end + + def create_match(player1_id, player2_id) + match_id = SecureRandom.uuid + game_token = JwtService.encode( + match_id: match_id, + player1_id: player1_id, + player2_id: player2_id, + game_server: GAME_SERVER, + exp: 1.hour.from_now.to_i + ) + + match_data = { + match_id: match_id, + game_token: game_token, + game_server: GAME_SERVER, + players: [player1_id, player2_id] + } + + # Store match data for both players + RedisConnection.with do |redis| + redis.set("#{MATCH_PREFIX}match:#{match_id}", match_data.to_json, ex: MATCH_TTL) + redis.set("#{MATCH_PREFIX}#{player1_id}", match_id, ex: MATCH_TTL) + redis.set("#{MATCH_PREFIX}#{player2_id}", match_id, ex: MATCH_TTL) + end + + match_data + end + end +end diff --git a/main-server/config/initializers/redis.rb b/main-server/config/initializers/redis.rb new file mode 100644 index 0000000..e2d123e --- /dev/null +++ b/main-server/config/initializers/redis.rb @@ -0,0 +1,7 @@ +# Redis configuration +# Development/test: localhost:6379 (default) +# Production: set REDIS_URL env variable, e.g. redis://redis.example.com:6379/0 + +RedisConnection = ConnectionPool.new(size: 5, timeout: 5) do + Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6380/0")) +end diff --git a/main-server/config/routes.rb b/main-server/config/routes.rb index aa5fc10..36434c1 100644 --- a/main-server/config/routes.rb +++ b/main-server/config/routes.rb @@ -4,6 +4,9 @@ Rails.application.routes.draw do post "login", to: "sessions#create" delete "logout", to: "sessions#destroy" get "me", to: "sessions#me" + + post "matchmaking", to: "matchmaking#create" + delete "matchmaking", to: "matchmaking#destroy" end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.