diff --git a/app/models/board.rb b/app/models/board.rb index 358a547..d1cb51d 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -63,10 +63,10 @@ def pattern @pattern ||= Pattern.find_by!(name: settings.name) end - def check_for_victory + def check_for_victory(user:) return unless game.status_sweep_in_progress? - all_safe_cells_have_been_revealed? and game.end_in_victory + all_safe_cells_have_been_revealed? and game.end_in_victory(user:) end def cells_at(coordinates_array) diff --git a/app/models/cell/reveal.rb b/app/models/cell/reveal.rb index c6b557d..deaf5cb 100644 --- a/app/models/cell/reveal.rb +++ b/app/models/cell/reveal.rb @@ -49,7 +49,7 @@ def already_revealed? end def start_game_if_standing_by - game.start(seed_cell: cell) + game.start(seed_cell: cell, user:) end def reveal_cell @@ -62,7 +62,7 @@ def reveal_cell def end_game_in_defeat_if_mine_revealed return unless cell.mine? - game.end_in_defeat + game.end_in_defeat(user:) throw(:return, self) end @@ -74,6 +74,6 @@ def recursively_reveal_neighbors_if_revealed_cell_was_blank end def end_game_in_victory_if_all_safe_cells_revealed - board.check_for_victory + board.check_for_victory(user:) end end diff --git a/app/models/cell/reveal_neighbors.rb b/app/models/cell/reveal_neighbors.rb index 8c3ae20..8bf81ea 100644 --- a/app/models/cell/reveal_neighbors.rb +++ b/app/models/cell/reveal_neighbors.rb @@ -87,11 +87,11 @@ def reveal_neighbor(neighboring_cell) def reveal(neighboring_cell) neighboring_cell.reveal - end_in_defeat if neighboring_cell.mine? + end_in_defeat(user:) if neighboring_cell.mine? end def end_in_defeat - game.end_in_defeat + game.end_in_defeat(user:) throw(:return, self) end @@ -102,6 +102,6 @@ def recursively_reveal_neighbors(neighboring_cell) end def end_game_in_victory_if_all_safe_cells_revealed - board.check_for_victory + board.check_for_victory(user:) end end diff --git a/app/models/game.rb b/app/models/game.rb index 5c5b630..4d5bb75 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -48,6 +48,10 @@ class Game < ApplicationRecord has_many :cell_flag_transactions, through: :cells has_many :cell_unflag_transactions, through: :cells + has_many :game_transactions, dependent: :delete_all + has_one :game_start_transaction + has_one :game_end_transaction + has_many :users, -> { select("DISTINCT ON(users.id) users.*").order("users.id") }, through: :cell_transactions @@ -115,11 +119,12 @@ def display_id = "##{id.to_s.rjust(self.class.display_id_width, "0")}" # :reek:TooManyStatements - def start(seed_cell:) + def start(seed_cell:, user:) return self unless status_standing_by? transaction do touch(:started_at) + GameStartTransaction.create_between(user:, game: self) board.on_game_start(seed_cell:) set_status_sweep_in_progress! end @@ -127,15 +132,17 @@ def start(seed_cell:) self end - def end_in_victory - end_game { + def end_in_victory(user:) + end_game(user:) { set_stats set_status_alliance_wins! } end - def end_in_defeat - end_game { set_status_mines_win! } + def end_in_defeat(user:) + end_game(user:) { + set_status_mines_win! + } end def on? @@ -163,11 +170,12 @@ def board_settings = board&.settings private - def end_game + def end_game(user:) return self if over? transaction do touch(:ended_at) + GameEndTransaction.create_between(user:, game: self) yield end @@ -242,7 +250,7 @@ def reset } end - # Like {#reset} but also resets status to "Standing By" and reset mines on + # Like {#reset} but also resets status to "Standing By" and resets mines on # the {Board}. def reset! do_reset { @@ -285,6 +293,7 @@ def do_reset bbbvps: nil, efficiency: nil) + game_transactions.clear CellTransaction.for_id(cell_transaction_ids).delete_all yield diff --git a/app/models/transactions/game_end_transaction.rb b/app/models/transactions/game_end_transaction.rb new file mode 100644 index 0000000..0e974b6 --- /dev/null +++ b/app/models/transactions/game_end_transaction.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# GameEndTransaction is a {GameTransaction} marking which {User} made the final +# move during the associated {Game}. +class GameEndTransaction < GameTransaction +end diff --git a/app/models/transactions/game_start_transaction.rb b/app/models/transactions/game_start_transaction.rb new file mode 100644 index 0000000..eb5a5f2 --- /dev/null +++ b/app/models/transactions/game_start_transaction.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# GameStartTransaction is a {GameTransaction} marking which {User} started the +# associated {Game}. +class GameStartTransaction < GameTransaction +end diff --git a/app/models/transactions/game_transaction.rb b/app/models/transactions/game_transaction.rb new file mode 100644 index 0000000..3a3bae7 --- /dev/null +++ b/app/models/transactions/game_transaction.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# GameTransaction records events transacted by {User}s on a {Game} and when they +# occurred. +# +# @attr type [String] The Subclass name. +# @attr user_id [Integer] References the {User} involved in this Transaction. +# @attr game_id [Integer] References the {Game} involved in this Transaction. +# @attr created_at [DateTime] When this Transaction occurred. +class GameTransaction < ApplicationRecord + self.implicit_order_column = "created_at" + + include AbstractBaseClassBehaviors + include ConsoleBehaviors + + as_abstract_class + + belongs_to :user + belongs_to :game + + scope :for_user, ->(user) { where(user:) } + scope :for_game, ->(game) { where(game:) } + + validates :game, uniqueness: { scope: :type } + + def self.create_between(user:, game:) + new(user:, game:).tap(&:save!) + end + + def self.exists_between?(user:, game:) + for_user(user).for_game(game).exists? + end + + # GameTransaction::Console acts like a {GameTransaction} but otherwise handles + # IRB Console-specific methods/logic. + class Console + include ConsoleObjectBehaviors + + private + + def inspect_info + [ + [user.inspect, game.inspect].join(" -> "), + I18n.l(created_at, format: :debug), + ].join(" @ ") + end + end +end diff --git a/db/migrate/20241112041937_create_game_transactions.rb b/db/migrate/20241112041937_create_game_transactions.rb new file mode 100644 index 0000000..611cd2c --- /dev/null +++ b/db/migrate/20241112041937_create_game_transactions.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Version: 20241112041937 +class CreateGameTransactions < ActiveRecord::Migration[8.0] + def change + create_table(:game_transactions) do |t| + t.string(:type, null: false, index: true) + t.references( + :user, type: :uuid, foreign_key: { on_delete: :nullify }, index: true) + t.references( + :game, null: false, foreign_key: { on_delete: :cascade }) + + t.datetime(:created_at, null: false, index: true) + end + + add_index(:game_transactions, %i[game_id type], unique: true) + end +end diff --git a/db/structure.sql b/db/structure.sql index 69e010a..3ea63af 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -136,6 +136,38 @@ CREATE SEQUENCE public.cells_id_seq ALTER SEQUENCE public.cells_id_seq OWNED BY public.cells.id; +-- +-- Name: game_transactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.game_transactions ( + id bigint NOT NULL, + type character varying NOT NULL, + user_id uuid, + game_id bigint NOT NULL, + created_at timestamp(6) with time zone NOT NULL +); + + +-- +-- Name: game_transactions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.game_transactions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: game_transactions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.game_transactions_id_seq OWNED BY public.game_transactions.id; + + -- -- Name: games; Type: TABLE; Schema: public; Owner: - -- @@ -250,6 +282,13 @@ ALTER TABLE ONLY public.cell_transactions ALTER COLUMN id SET DEFAULT nextval('p ALTER TABLE ONLY public.cells ALTER COLUMN id SET DEFAULT nextval('public.cells_id_seq'::regclass); +-- +-- Name: game_transactions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions ALTER COLUMN id SET DEFAULT nextval('public.game_transactions_id_seq'::regclass); + + -- -- Name: games id; Type: DEFAULT; Schema: public; Owner: - -- @@ -296,6 +335,14 @@ ALTER TABLE ONLY public.cells ADD CONSTRAINT cells_pkey PRIMARY KEY (id); +-- +-- Name: game_transactions game_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions + ADD CONSTRAINT game_transactions_pkey PRIMARY KEY (id); + + -- -- Name: games games_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -426,6 +473,41 @@ CREATE INDEX index_cells_on_mine ON public.cells USING btree (mine); CREATE INDEX index_cells_on_revealed ON public.cells USING btree (revealed); +-- +-- Name: index_game_transactions_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_created_at ON public.game_transactions USING btree (created_at); + + +-- +-- Name: index_game_transactions_on_game_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_game_id ON public.game_transactions USING btree (game_id); + + +-- +-- Name: index_game_transactions_on_game_id_and_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_game_transactions_on_game_id_and_type ON public.game_transactions USING btree (game_id, type); + + +-- +-- Name: index_game_transactions_on_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_type ON public.game_transactions USING btree (type); + + +-- +-- Name: index_game_transactions_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_user_id ON public.game_transactions USING btree (user_id); + + -- -- Name: index_games_on_bbbv; Type: INDEX; Schema: public; Owner: - -- @@ -526,6 +608,14 @@ ALTER TABLE ONLY public.cell_transactions ADD CONSTRAINT fk_rails_8ae22ea0ff FOREIGN KEY (cell_id) REFERENCES public.cells(id) ON DELETE CASCADE; +-- +-- Name: game_transactions fk_rails_adddf1bd52; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions + ADD CONSTRAINT fk_rails_adddf1bd52 FOREIGN KEY (game_id) REFERENCES public.games(id) ON DELETE CASCADE; + + -- -- Name: cell_transactions fk_rails_baaa458a22; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -534,6 +624,14 @@ ALTER TABLE ONLY public.cell_transactions ADD CONSTRAINT fk_rails_baaa458a22 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; +-- +-- Name: game_transactions fk_rails_c020c31c6a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions + ADD CONSTRAINT fk_rails_c020c31c6a FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + -- -- Name: cells fk_rails_d04db06fd5; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -549,6 +647,7 @@ ALTER TABLE ONLY public.cells SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20241112041937'), ('20240927195322'), ('20240912030247'), ('20240911180310'), diff --git a/lib/console_object_behaviors.rb b/lib/console_object_behaviors.rb index a2f78df..0b5bfe8 100644 --- a/lib/console_object_behaviors.rb +++ b/lib/console_object_behaviors.rb @@ -19,16 +19,10 @@ module ConsoleObjectBehaviors define_method(:__class__) { self.class.module_parent } try(:reflections)&.each do |name, reflection| - if reflection.macro.in?(%i[has_one belongs_to]) - define_method(name) do - __model__.public_send(name).try(:console) - end - elsif reflection.macro == :has_many - define_method(name) do - __model__.public_send(name).by_most_recent.map { |record| - record.try(:console) - } - end + next unless reflection.macro.in?(%i[has_one belongs_to]) + + define_method(name) do + __model__.public_send(name).try(:console) end end end diff --git a/test/fixtures/game_transactions.yml b/test/fixtures/game_transactions.yml new file mode 100644 index 0000000..a192ca3 --- /dev/null +++ b/test/fixtures/game_transactions.yml @@ -0,0 +1,17 @@ +win1_game_start_transaction: + type: GameStartTransaction + user: user1 + game: win1 +win1_game_end_transaction: + type: GameEndTransaction + user: user1 + game: win1 + +loss1_game_start_transaction: + type: GameStartTransaction + user: user1 + game: loss1 +loss1_game_end_transaction: + type: GameEndTransaction + user: user2 + game: loss1 diff --git a/test/fixtures/games.yml b/test/fixtures/games.yml index 7ab554f..1ff4208 100644 --- a/test/fixtures/games.yml +++ b/test/fixtures/games.yml @@ -11,9 +11,8 @@ loss1: <<: *DEFAULTS status: <%= Game.status_mines_win %> started_at: <%= 30.seconds.ago %> - ended_at: <%= 10.second.ago %> + ended_at: <%= 10.seconds.ago %> standing_by1: <<: *DEFAULTS status: <%= Game.status_standing_by %> - started_at: <%= Time.current %> diff --git a/test/models/board_test.rb b/test/models/board_test.rb index 911be61..bd3343e 100644 --- a/test/models/board_test.rb +++ b/test/models/board_test.rb @@ -15,6 +15,8 @@ class BoardTest < ActiveSupport::TestCase let(:preset_settings1) { unit_class::Settings.beginner } let(:custom_settings1) { unit_class::Settings[4, 4, 1] } + let(:user1) { users(:user1) } + describe ".new" do it "returns the expected Board" do result = unit_class.new(settings: preset_settings1) @@ -135,7 +137,7 @@ class BoardTest < ActiveSupport::TestCase subject { [standing_by1_board, win1_board, loss1_board].sample } it "doesn't orchestrate any changes, and returns nil" do - result = subject.check_for_victory + result = subject.check_for_victory(user: user1) _(result).must_be_nil _(@end_in_victory_call).must_be_nil end @@ -144,13 +146,13 @@ class BoardTest < ActiveSupport::TestCase context "GIVEN the associated Game#status_sweep_in_progress? = true" do context "GIVEN the Board is not yet in a victorious state" do before do - subject.game.start(seed_cell: nil) + subject.game.start(seed_cell: nil, user: user1) end subject { standing_by1_board } it "doesn't call Game#end_in_vicotry, and returns false" do - result = subject.check_for_victory + result = subject.check_for_victory(user: user1) _(result).must_equal(false) _(@end_in_victory_call).must_be_nil end @@ -158,7 +160,7 @@ class BoardTest < ActiveSupport::TestCase context "GIVEN the Board is in a victorious state" do before do - subject.game.start(seed_cell: nil) + subject.game.start(seed_cell: nil, user: user1) subject.cells.is_not_mine.update_all(revealed: true) subject.cells.reload end @@ -166,7 +168,7 @@ class BoardTest < ActiveSupport::TestCase subject { standing_by1_board } it "calls Game#end_in_vicotry, and returns the Game" do - result = subject.check_for_victory + result = subject.check_for_victory(user: user1) _(result).must_be_same_as(subject.game) _(@end_in_victory_call).wont_be_nil end diff --git a/test/models/game_test.rb b/test/models/game_test.rb index 54d13eb..8746dd4 100644 --- a/test/models/game_test.rb +++ b/test/models/game_test.rb @@ -14,6 +14,7 @@ class GameTest < ActiveSupport::TestCase let(:new_game) { unit_class.new } let(:preset_settings1) { Board::Settings.beginner } let(:custom_settings1) { Board::Settings[6, 6, 4] } + let(:user1) { users(:user1) } context "Class Methods" do subject { unit_class } @@ -27,7 +28,10 @@ class GameTest < ActiveSupport::TestCase context "GIVEN a Game#on? = true Game doesn't exist" do context "GIVEN a recently-ended Game exists" do - before { recently_ended_game.end_in_victory } + before do + recently_ended_game.touch(:started_at) + recently_ended_game.end_in_victory(user: user1) + end let(:recently_ended_game) { standing_by1 } @@ -174,7 +178,7 @@ class GameTest < ActiveSupport::TestCase it "orchestrates the expected updates and returns the Game" do result = - _(-> { subject.start(seed_cell: nil) }).must_change_all([ + _(-> { subject.start(seed_cell: nil, user: user1) }).must_change_all([ ["subject.started_at"], [ "subject.status", @@ -192,7 +196,7 @@ class GameTest < ActiveSupport::TestCase it "returns the Game without orchestrating any changes" do result = - _(-> { subject.start(seed_cell: nil) }).wont_change_all([ + _(-> { subject.start(seed_cell: nil, user: user1) }).wont_change_all([ ["subject.started_at"], ["subject.status"], ]) @@ -204,22 +208,22 @@ class GameTest < ActiveSupport::TestCase describe "#end_in_victory" do context "GIVEN a Game that's still on" do - subject { standing_by1.start(seed_cell: nil) } + subject { standing_by1.start(seed_cell: nil, user: user1) } it "touches Game#ended_at" do - _(-> { subject.end_in_victory }).must_change( + _(-> { subject.end_in_victory(user: user1) }).must_change( "subject.ended_at", from: nil) end it "sets the expected Status" do - _(-> { subject.end_in_victory }).must_change( + _(-> { subject.end_in_victory(user: user1) }).must_change( "subject.status", to: unit_class.status_alliance_wins) end it "sets Game stats" do - _(-> { subject.end_in_victory }).must_change_all([ + _(-> { subject.end_in_victory(user: user1) }).must_change_all([ ["subject.score", from: nil], ["subject.bbbv", from: nil], ["subject.bbbvps", from: nil], @@ -238,7 +242,7 @@ class GameTest < ActiveSupport::TestCase subject { win1 } it "returns the Game without orchestrating any changes" do - result = subject.end_in_victory + result = subject.end_in_victory(user: user1) _(result).must_be_same_as(subject) _(@touch_call).must_be_nil end @@ -247,22 +251,22 @@ class GameTest < ActiveSupport::TestCase describe "#end_in_defeat" do context "GIVEN a Game that's still on" do - subject { standing_by1.start(seed_cell: nil) } + subject { standing_by1.start(seed_cell: nil, user: user1) } it "touches Game#ended_at" do - _(-> { subject.end_in_defeat }).must_change( + _(-> { subject.end_in_defeat(user: user1) }).must_change( "subject.ended_at", from: nil) end it "sets the expected Status" do - _(-> { subject.end_in_defeat }).must_change( + _(-> { subject.end_in_defeat(user: user1) }).must_change( "subject.status", to: unit_class.status_mines_win) end it "doesn't set Game stats" do - _(-> { subject.end_in_defeat }).wont_change_all([ + _(-> { subject.end_in_defeat(user: user1) }).wont_change_all([ ["subject.score", from: nil], ["subject.bbbv", from: nil], ["subject.bbbvps", from: nil], @@ -281,7 +285,7 @@ class GameTest < ActiveSupport::TestCase subject { win1 } it "returns the Game without orchestrating any changes" do - result = subject.end_in_defeat + result = subject.end_in_defeat(user: user1) _(result).must_be_same_as(subject) _(@touch_call).must_be_nil end diff --git a/test/models/game_transaction_test.rb b/test/models/game_transaction_test.rb new file mode 100644 index 0000000..b9b916b --- /dev/null +++ b/test/models/game_transaction_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "test_helper" + +class GameTransactionTest < ActiveSupport::TestCase + describe "GameTransaction" do + let(:unit_class) { GameTransaction } + + let(:user1) { users(:user1) } + let(:user2) { users(:user2) } + + let(:win1) { games(:win1) } + let(:standing_by1) { games(:standing_by1) } + + describe "DB insertion (GIVEN no Rails validation)" do + subject { GameStartTransaction.new(user: user1, game: win1) } + + it "raises ActiveRecord::RecordNotUnique" do + exception = + _(-> { + subject.save(validate: false) + }).must_raise(ActiveRecord::RecordNotUnique) + + _(exception.message).must_include( + "PG::UniqueViolation: ERROR: "\ + "duplicate key value violates unique constraint "\ + '"index_game_transactions_on_game_id_and_type"') + end + end + + describe ".create_between" do + context "GIVEN a new, unique pair" do + subject { GameStartTransaction } + + it "creates the expected GameTransaction record, and returns it" do + result = + _(-> { + subject.create_between(user: user2, game: standing_by1) + }).must_change("GameTransaction.count") + _(result).must_be_instance_of(subject) + _(result.user).must_be_same_as(user2) + _(result.game).must_be_same_as(standing_by1) + end + end + + context "GIVEN an existing, non-unique pair" do + subject { [GameStartTransaction, GameEndTransaction].sample } + + it "raises ActiveRecord::RecordInvalid" do + exception = + _(-> { + subject.create_between(user: user1, game: win1) + }).must_raise(ActiveRecord::RecordInvalid) + + _(exception.message).must_equal( + "Validation failed: Game has already been taken") + end + end + end + + describe ".exists_between?" do + subject { unit_class } + + context "GIVEN an existing pair" do + it "returns true" do + result = subject.exists_between?(user: user1, game: win1) + _(result).must_equal(true) + end + end + + context "GIVEN a non-existent pair" do + it "returns false" do + result = subject.exists_between?(user: user2, game: win1) + _(result).must_equal(false) + end + end + end + end +end