add Match model
This commit is contained in:
@@ -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
|
||||||
@@ -2,6 +2,8 @@ class User < ApplicationRecord
|
|||||||
has_secure_password validations: false
|
has_secure_password validations: false
|
||||||
|
|
||||||
has_many :sessions, dependent: :destroy
|
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
|
has_one_attached :avatar
|
||||||
|
|
||||||
normalizes :nickname, with: ->(n) { n.strip }
|
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? }
|
validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || password_digest_changed? }
|
||||||
validate :avatar_content_type, if: -> { avatar.attached? }
|
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
|
private
|
||||||
def avatar_content_type
|
def avatar_content_type
|
||||||
unless avatar.blob.content_type.in?(%w[image/png image/jpeg image/gif image/webp])
|
unless avatar.blob.content_type.in?(%w[image/png image/jpeg image/gif image/webp])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", 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
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
end
|
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|
|
create_table "sessions", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "ip_address"
|
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_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "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"
|
add_foreign_key "sessions", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -97,4 +97,33 @@ RSpec.describe User, type: :model do
|
|||||||
expect(user.update(nickname: "new_nickname")).to be true
|
expect(user.update(nickname: "new_nickname")).to be true
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user