From f48094cd553d47fdbff9bbcbdf135ce7c02ee2aa Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 3 Nov 2026 16:14:10 +0300 Subject: [PATCH] add Match model --- main-server/app/models/match.rb | 43 +++++++ main-server/app/models/user.rb | 9 ++ .../migrate/20261103000000_create_matches.rb | 24 ++++ main-server/db/schema.rb | 26 ++++- main-server/spec/models/match_spec.rb | 106 ++++++++++++++++++ main-server/spec/models/user_spec.rb | 29 +++++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 main-server/app/models/match.rb create mode 100644 main-server/db/migrate/20261103000000_create_matches.rb create mode 100644 main-server/spec/models/match_spec.rb diff --git a/main-server/app/models/match.rb b/main-server/app/models/match.rb new file mode 100644 index 0000000..45d7f2a --- /dev/null +++ b/main-server/app/models/match.rb @@ -0,0 +1,43 @@ +class Match < ApplicationRecord + belongs_to :player1, class_name: "User" + belongs_to :player2, class_name: "User" + + enum :status, { + ongoing: 0, + player1_won: 1, + player2_won: 2, + draw: 3, + abandoned: 4 + } + + before_validation :assign_uuid, on: :create + + validates :uuid, presence: true, uniqueness: true + validates :status, presence: true + validates :game_server, presence: true + validates :started_at, presence: true + validates :duration_seconds, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true + validates :player1_rating_before, :player2_rating_before, + presence: true, numericality: { only_integer: true } + validates :player1_rating_change, :player2_rating_change, + presence: true, numericality: { only_integer: true } + validate :players_must_differ + + scope :finished, -> { where.not(finished_at: nil) } + scope :for_user, ->(user_or_id) { + id = user_or_id.is_a?(User) ? user_or_id.id : user_or_id + where(player1_id: id).or(where(player2_id: id)) + } + + private + + def assign_uuid + self.uuid ||= SecureRandom.uuid + end + + def players_must_differ + if player1_id.present? && player1_id == player2_id + errors.add(:player2, "must differ from player1") + end + end +end diff --git a/main-server/app/models/user.rb b/main-server/app/models/user.rb index 6af9418..5b8aa33 100644 --- a/main-server/app/models/user.rb +++ b/main-server/app/models/user.rb @@ -2,6 +2,8 @@ class User < ApplicationRecord has_secure_password validations: false has_many :sessions, dependent: :destroy + has_many :matches_as_player1, class_name: "Match", foreign_key: :player1_id, dependent: :restrict_with_exception, inverse_of: :player1 + has_many :matches_as_player2, class_name: "Match", foreign_key: :player2_id, dependent: :restrict_with_exception, inverse_of: :player2 has_one_attached :avatar normalizes :nickname, with: ->(n) { n.strip } @@ -11,6 +13,13 @@ class User < ApplicationRecord validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || password_digest_changed? } validate :avatar_content_type, if: -> { avatar.attached? } + # All matches of this user that have finished, newest first. + def finished_matches + Match.where("player1_id = :id OR player2_id = :id", id: id) + .where.not(finished_at: nil) + .order(finished_at: :desc) + end + private def avatar_content_type unless avatar.blob.content_type.in?(%w[image/png image/jpeg image/gif image/webp]) diff --git a/main-server/db/migrate/20261103000000_create_matches.rb b/main-server/db/migrate/20261103000000_create_matches.rb new file mode 100644 index 0000000..8336d74 --- /dev/null +++ b/main-server/db/migrate/20261103000000_create_matches.rb @@ -0,0 +1,24 @@ +class CreateMatches < ActiveRecord::Migration[8.1] + def change + create_table :matches do |t| + t.string :uuid, null: false + t.references :player1, null: false, foreign_key: { to_table: :users } + t.references :player2, null: false, foreign_key: { to_table: :users } + t.integer :status, null: false, default: 0 + t.text :game_server, null: false + t.datetime :started_at, null: false + t.datetime :finished_at + t.integer :duration_seconds + t.integer :player1_rating_before, null: false + t.integer :player2_rating_before, null: false + t.integer :player1_rating_change, null: false, default: 0 + t.integer :player2_rating_change, null: false, default: 0 + + t.timestamps + end + + add_index :matches, :uuid, unique: true + add_index :matches, [ :player1_id, :finished_at ] + add_index :matches, [ :player2_id, :finished_at ] + end +end diff --git a/main-server/db/schema.rb b/main-server/db/schema.rb index 25ec162..c9ada3c 100644 --- a/main-server/db/schema.rb +++ b/main-server/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_11_03_000000) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -39,6 +39,28 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "matches", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "duration_seconds" + t.datetime "finished_at" + t.text "game_server", null: false + t.integer "player1_id", null: false + t.integer "player1_rating_before", null: false + t.integer "player1_rating_change", default: 0, null: false + t.integer "player2_id", null: false + t.integer "player2_rating_before", null: false + t.integer "player2_rating_change", default: 0, null: false + t.datetime "started_at", null: false + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.string "uuid", null: false + t.index ["player1_id", "finished_at"], name: "index_matches_on_player1_id_and_finished_at" + t.index ["player1_id"], name: "index_matches_on_player1_id" + t.index ["player2_id", "finished_at"], name: "index_matches_on_player2_id_and_finished_at" + t.index ["player2_id"], name: "index_matches_on_player2_id" + t.index ["uuid"], name: "index_matches_on_uuid", unique: true + end + create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address" @@ -59,5 +81,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "matches", "users", column: "player1_id" + add_foreign_key "matches", "users", column: "player2_id" add_foreign_key "sessions", "users" end diff --git a/main-server/spec/models/match_spec.rb b/main-server/spec/models/match_spec.rb new file mode 100644 index 0000000..3d59b1b --- /dev/null +++ b/main-server/spec/models/match_spec.rb @@ -0,0 +1,106 @@ +require "rails_helper" + +RSpec.describe Match, type: :model do + let(:player1) { User.create!(nickname: "player_one", password: "secret123") } + let(:player2) { User.create!(nickname: "player_two", password: "secret123") } + + let(:valid_attributes) do + { + player1: player1, + player2: player2, + status: :ongoing, + game_server: "ws://game-server:8080/ws", + started_at: Time.current, + player1_rating_before: 100, + player2_rating_before: 100, + player1_rating_change: 0, + player2_rating_change: 0 + } + end + + it "is valid with valid attributes" do + expect(Match.new(valid_attributes)).to be_valid + end + + it "generates a uuid automatically" do + match = Match.create!(valid_attributes) + expect(match.uuid).to match(/\A[0-9a-f-]{36}\z/) + end + + it "belongs to two players" do + match = Match.create!(valid_attributes) + expect(match.player1).to eq(player1) + expect(match.player2).to eq(player2) + end + + it "does not allow the same player on both sides" do + match = Match.new(valid_attributes.merge(player2: player1)) + expect(match).not_to be_valid + expect(match.errors[:player2]).to be_present + end + + it "requires a game_server" do + expect(Match.new(valid_attributes.merge(game_server: nil))).not_to be_valid + end + + it "requires started_at" do + expect(Match.new(valid_attributes.merge(started_at: nil))).not_to be_valid + end + + it "allows finished_at and duration_seconds to be nil" do + expect(Match.new(valid_attributes)).to be_valid + end + + it "rejects negative duration_seconds" do + match = Match.new(valid_attributes.merge(duration_seconds: -1)) + expect(match).not_to be_valid + end + + it "rejects nil rating changes" do + match = Match.new(valid_attributes.merge(player1_rating_change: nil)) + expect(match).not_to be_valid + end + + it "allows zero rating changes" do + match = Match.new(valid_attributes.merge(player1_rating_change: 0, player2_rating_change: 0)) + expect(match).to be_valid + end + + describe "statuses" do + it "defines all statuses" do + expect(Match.statuses.keys).to contain_exactly( + "ongoing", "player1_won", "player2_won", "draw", "abandoned" + ) + end + + it "defaults to ongoing" do + match = Match.create!(valid_attributes) + expect(match.status).to eq("ongoing") + end + + it "supports transitions to final statuses" do + match = Match.create!(valid_attributes) + match.player1_won! + expect(match.reload.status).to eq("player1_won") + end + end + + describe ".finished" do + it "returns only matches with finished_at set" do + finished = Match.create!(valid_attributes.merge(finished_at: Time.current)) + Match.create!(valid_attributes) + expect(Match.finished).to contain_exactly(finished) + end + end + + describe ".for_user" do + it "returns matches where the user is either player" do + other = User.create!(nickname: "player_three", password: "secret123") + m1 = Match.create!(valid_attributes) + m2 = Match.create!(valid_attributes.merge(player1: player2, player2: player1)) + Match.create!(valid_attributes.merge(player1: player2, player2: other)) + + expect(Match.for_user(player1)).to contain_exactly(m1, m2) + end + end +end diff --git a/main-server/spec/models/user_spec.rb b/main-server/spec/models/user_spec.rb index f293b18..7f85e19 100644 --- a/main-server/spec/models/user_spec.rb +++ b/main-server/spec/models/user_spec.rb @@ -97,4 +97,33 @@ RSpec.describe User, type: :model do expect(user.update(nickname: "new_nickname")).to be true end end + + describe "#finished_matches" do + let(:user) { User.create!(valid_attributes) } + let(:opponent) { User.create!(nickname: "opponent", password: "secret123") } + let(:outsider) { User.create!(nickname: "outsider", password: "secret123") } + + def build_match(attrs) + Match.create!({ + player1: user, + player2: opponent, + status: :ongoing, + game_server: "ws://game-server:8080/ws", + started_at: Time.current, + player1_rating_before: 100, + player2_rating_before: 100, + player1_rating_change: 0, + player2_rating_change: 0 + }.merge(attrs)) + end + + it "returns only finished matches of the user ordered by finished_at desc" do + older = build_match(status: :player1_won, finished_at: 2.days.ago) + newer = build_match(player1: opponent, player2: user, status: :player2_won, finished_at: 1.day.ago) + build_match(status: :ongoing, finished_at: nil) + build_match(player1: opponent, player2: outsider, status: :player1_won, finished_at: Time.current) + + expect(user.finished_matches.to_a).to eq([ newer, older ]) + end + end end