From 083b9ba5436c9045cd6b6dc7d97a639ca36a2b1b Mon Sep 17 00:00:00 2001 From: Michael Dao Date: Tue, 20 May 2025 16:38:39 -0400 Subject: [PATCH 1/8] Add BuildingType model, migration, and tests Create BuildingType model with validations and methods for cost, construction time, and effects based on level. Add migration to create building_types table with necessary fields. Include FactoryBot factory for building types and RSpec tests to validate model behavior and instance methods. --- app/models/building_type.rb | 23 +++++++++ .../20250520201621_create_building_types.rb | 15 ++++++ db/schema.rb | 14 +++++- spec/factories/building_types.rb | 38 +++++++++++++++ spec/models/building_type_spec.rb | 48 +++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/models/building_type.rb create mode 100644 db/migrate/20250520201621_create_building_types.rb create mode 100644 spec/factories/building_types.rb create mode 100644 spec/models/building_type_spec.rb diff --git a/app/models/building_type.rb b/app/models/building_type.rb new file mode 100644 index 0000000..7072388 --- /dev/null +++ b/app/models/building_type.rb @@ -0,0 +1,23 @@ +class BuildingType < ApplicationRecord + validates :key, presence: true, uniqueness: true + validates :name, presence: true + validates :description, presence: true + validates :max_level, presence: true, numericality: { greater_than: 0 } + + def cost_for_level(level) + level_data.dig(level.to_s, "cost") + end + + def construction_time_for_level(level) + level_data.dig(level.to_s, "construction_time") + end + + def demolition_time_for_level(level) + level_data.dig(level.to_s, "demolition_time") + end + + def effects_for_level(level) + level_data.dig(level.to_s, "effects") + end +end + diff --git a/db/migrate/20250520201621_create_building_types.rb b/db/migrate/20250520201621_create_building_types.rb new file mode 100644 index 0000000..c45e691 --- /dev/null +++ b/db/migrate/20250520201621_create_building_types.rb @@ -0,0 +1,15 @@ +class CreateBuildingTypes < ActiveRecord::Migration[7.1] + def change + create_table :building_types do |t| + t.string :key, null: false + t.string :name, null: false + t.text :description, null: false + t.boolean :unique_per_system, default: false + t.integer :max_level, default: 1, null: false + t.json :level_data, default: {}, null: false + t.json :prerequisites, default: {}, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c878c84..439e0b4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,19 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_05_18_141932) do +ActiveRecord::Schema[7.1].define(version: 2025_05_20_201621) do + create_table "building_types", force: :cascade do |t| + t.string "key", null: false + t.string "name", null: false + t.text "description", null: false + t.boolean "unique_per_system", default: false + t.integer "max_level", default: 1, null: false + t.json "level_data", default: {}, null: false + t.json "prerequisites", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "empires", force: :cascade do |t| t.string "name" t.integer "credits", default: 1000 diff --git a/spec/factories/building_types.rb b/spec/factories/building_types.rb new file mode 100644 index 0000000..ecf87ac --- /dev/null +++ b/spec/factories/building_types.rb @@ -0,0 +1,38 @@ +FactoryBot.define do + factory :building_type do + key { "government_administration" } + name { "Government Administration Building" } + description { "A central administrative complex that improves tax collection efficiency." } + unique_per_system { true } + max_level { 5 } + level_data { + { + "1" => { + construction_time: 8.hours.to_i, + demolition_time: 1.hour.to_i, + cost: { + credits: 200, + minerals: 100, + energy: 125 + }, + effects: { + tax_modifier: 0.05 + } + }, + "2" => { + construction_time: 12.hours.to_i, + demolition_time: 1.hour.to_i, + cost: { + credits: 400, + minerals: 200, + energy: 250 + }, + effects: { + tax_modifier: 0.10 + } + } + } + } + prerequisites { {} } + end +end diff --git a/spec/models/building_type_spec.rb b/spec/models/building_type_spec.rb new file mode 100644 index 0000000..8734514 --- /dev/null +++ b/spec/models/building_type_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe BuildingType, type: :model do + subject { create(:building_type) } + # Validation tests + it { should validate_presence_of(:key) } + it { should validate_uniqueness_of(:key) } + it { should validate_presence_of(:name) } + it { should validate_presence_of(:description) } + it { should validate_presence_of(:max_level) } + it { should validate_numericality_of(:max_level).is_greater_than(0) } + + # Factory test + it "has a valid factory" do + expect(build(:building_type)).to be_valid + end + + # Instance methods + describe "#cost_for_level" do + let(:building_type) { create(:building_type) } + + it "returns the correct cost for a given level" do + expect(building_type.cost_for_level(1)).to eq({ + "credits" => 200, + "minerals" => 100, + "energy" => 125 + }) + end + end + + describe "#construction_time_for_level" do + let(:building_type) { create(:building_type) } + + it "returns the correct construction time for a given level" do + expect(building_type.construction_time_for_level(1)).to eq(8.hours.to_i) + end + end + + describe "#effects_for_level" do + let(:building_type) { create(:building_type) } + + it "returns the correct effects for a given level" do + expect(building_type.effects_for_level(1)).to eq({ + "tax_modifier" => 0.05 + }) + end + end +end From 3f82a4ae4bd310e65578b210baaeb76fee495af5 Mon Sep 17 00:00:00 2001 From: Michael Dao Date: Tue, 20 May 2025 21:50:00 -0400 Subject: [PATCH 2/8] Add Building model, migration, and tests Create Building model with associations, validations, and methods for construction and demolition progress. Add migration to create buildings table with necessary fields and foreign keys. Include FactoryBot factory for buildings and RSpec tests to validate model behavior and instance methods. --- app/models/building.rb | 69 ++++++++ db/migrate/20250521011436_create_buildings.rb | 17 ++ db/schema.rb | 19 +- spec/factories/buildings.rb | 22 +++ spec/models/building_spec.rb | 162 ++++++++++++++++++ 5 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 app/models/building.rb create mode 100644 db/migrate/20250521011436_create_buildings.rb create mode 100644 spec/factories/buildings.rb create mode 100644 spec/models/building_spec.rb diff --git a/app/models/building.rb b/app/models/building.rb new file mode 100644 index 0000000..09cef88 --- /dev/null +++ b/app/models/building.rb @@ -0,0 +1,69 @@ +class Building < ApplicationRecord + belongs_to :building_type + belongs_to :star_system + + # Statuses + STATUSES = %w[under_construction being_demolished operational].freeze + + # Validations + validates :level, presence: true, numericality: { greater_than: 0 } + validates :status, presence: true, inclusion: { in: STATUSES } + validate :unique_per_system, if: -> { building_type&.unique_per_system? } + + # Status methods + def under_construction? + status == "under_construction" + end + + def operational? + status == "operational" + end + + def being_demolished? + status == "being_demolished" + end + + # Progress calculation methods + def construction_progress_percentage + return 100 if operational? || being_demolished? + return 0 if !construction_start || !construction_end + + elapsed_time = Time.current - construction_start + total_time = construction_end - construction_start + + progress = (elapsed_time / total_time * 100).round + [progress, 100].min + end + + def demolition_progress_percentage + return 0 unless being_demolished? + return 0 if !updated_at || !demolition_end + + elapsed_time = Time.current - updated_at + total_time = demolition_end - updated_at + + progress = (elapsed_time / total_time * 100).round + [progress, 100].min + end + + # Effects methods + def current_effect(effect_key) + return nil if under_construction? || being_demolished? + + building_type.effects[effect_key] * level + end + + private + + + def unique_per_system + return unless building_type.unique_per_system? + + existing = Building.where(building_type_id: building_type_id, star_system_id: star_system_id) + existing = existing.where.not(id: id) if persisted? + + if existing.exists? + errors.add(:building_type_id, "already exists in this star system") + end + end +end diff --git a/db/migrate/20250521011436_create_buildings.rb b/db/migrate/20250521011436_create_buildings.rb new file mode 100644 index 0000000..7ca7e01 --- /dev/null +++ b/db/migrate/20250521011436_create_buildings.rb @@ -0,0 +1,17 @@ +class CreateBuildings < ActiveRecord::Migration[7.1] + def change + create_table :buildings do |t| + t.references :building_type, null: false, foreign_key: true + t.references :star_system, null: false, foreign_key: true + t.integer :level, null: false, default: 1 + t.string :status, null: false, default: "under_construction" + t.datetime :construction_start + t.datetime :construction_end + t.datetime :demolition_end + + t.timestamps + end + + add_index :buildings, [:building_type_id, :star_system_id], name: 'index_buildings_on_building_type_and_star_system' + end +end diff --git a/db/schema.rb b/db/schema.rb index 439e0b4..1fcada3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_05_20_201621) do +ActiveRecord::Schema[7.1].define(version: 2025_05_21_011436) do create_table "building_types", force: :cascade do |t| t.string "key", null: false t.string "name", null: false @@ -23,6 +23,21 @@ t.datetime "updated_at", null: false end + create_table "buildings", force: :cascade do |t| + t.integer "building_type_id", null: false + t.integer "star_system_id", null: false + t.integer "level", default: 1, null: false + t.string "status", default: "under_construction", null: false + t.datetime "construction_start" + t.datetime "construction_end" + t.datetime "demolition_end" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["building_type_id", "star_system_id"], name: "index_buildings_on_building_type_and_star_system" + t.index ["building_type_id"], name: "index_buildings_on_building_type_id" + t.index ["star_system_id"], name: "index_buildings_on_star_system_id" + end + create_table "empires", force: :cascade do |t| t.string "name" t.integer "credits", default: 1000 @@ -60,6 +75,8 @@ t.index ["username"], name: "index_users_on_username", unique: true end + add_foreign_key "buildings", "building_types" + add_foreign_key "buildings", "star_systems" add_foreign_key "empires", "users" add_foreign_key "star_systems", "empires" end diff --git a/spec/factories/buildings.rb b/spec/factories/buildings.rb new file mode 100644 index 0000000..331dc66 --- /dev/null +++ b/spec/factories/buildings.rb @@ -0,0 +1,22 @@ +FactoryBot.define do + factory :building do + association :building_type + association :star_system + level { 1 } + status { "operational" } + construction_start { 10.hours.ago } + construction_end { 2.hours.ago } + demolition_end { nil } + + trait :under_construction do + status { "under_construction" } + construction_start { 2.hours.ago } + construction_end { 6.hours.from_now } + end + + trait :being_demolished do + status { "being_demolished" } + demolition_end { 1.hour.from_now } + end + end +end diff --git a/spec/models/building_spec.rb b/spec/models/building_spec.rb new file mode 100644 index 0000000..19e851e --- /dev/null +++ b/spec/models/building_spec.rb @@ -0,0 +1,162 @@ +require 'rails_helper' + +RSpec.describe Building, type: :model do + # Association Tests + it { should belong_to(:building_type) } + it { should belong_to(:star_system) } + + # Validation Tests + it { should validate_presence_of(:level) } + it { should validate_numericality_of(:level).only_integer.is_greater_than(0) } + it { should validate_presence_of(:status) } + it { should validate_inclusion_of(:status).in_array(%w[under_construction being_demolished operational]) } + + # Factory Tests + it "has a valid factory" do + expect(build(:building)).to be_valid + end + + it "has a valid factory for under construction" do + expect(build(:building, :under_construction)).to be_valid + end + + it "has a valid factory for being demolished" do + expect(build(:building, :being_demolished)).to be_valid + end + + # Instance Methods + describe "#under_construction?" do + it "returns true if the building is under construction" do + building = build(:building, :under_construction) + expect(building.under_construction?).to be true + end + + it "returns false if the building is not under construction" do + building = build(:building) + expect(building.under_construction?).to be false + end + end + + describe "#operational?" do + it "returns true if the building is operational" do + building = build(:building, status: "operational") + expect(building.operational?).to be true + end + + it "returns false if the building is not operational" do + building = build(:building, status: "under_construction") + expect(building.operational?).to be false + end + end + + describe "#being_demolished?" do + it "returns true if the building is being demolished" do + building = build(:building, :being_demolished) + expect(building.being_demolished?).to be true + end + + it "returns false if the building is not being demolished" do + building = build(:building) + expect(building.being_demolished?).to be false + end + end + + describe "#construction_progress_percentage" do + it "returns 0 if construction has not started" do + building = build(:building, construction_start: nil, construction_end: nil) + expect(building.construction_progress_percentage).to eq(0) + end + + it "returns 100 if construction is complete" do + building = build(:building, status: "operational") + expect(building.construction_progress_percentage).to eq(100) + end + + it "calculates the correct percentage for in progress construction" do + # set specific times for predictable result + start_time = 2.hours.ago + end_time = 2.hours.from_now + + building = build(:building, status: "under_construction", + construction_start: start_time, + construction_end: end_time) + + allow(Time).to receive(:now).and_return(start_time + 2.hours) + expect(building.construction_progress_percentage).to eq(50) + end + end + + describe "#demolition_progress_percentage" do + it "returns 0 if demolition has not started" do + building = build(:building, status: "operational") + expect(building.demolition_progress_percentage).to eq(0) + end + + it "calculates the correct percentage for in progress demolition" do + # set up 1 hour demolition process, half complete + demolition_start = 30.minutes.ago + demolition_end = 30.minutes.from_now + + building = build(:building, :being_demolished, demolition_end: demolition_end) + + # We'll need to stub the created_at since demolition_start comes from updated_at + allow(building).to receive(:updated_at).and_return(demolition_start) + allow(Time).to receive(:current).and_return(demolition_start + 30.minutes) + + expect(building.demolition_progress_percentage).to eq(50) + end + end + + describe "#current_effect" do + let(:building_type) { create(:building_type) } + let(:building) { create(:building, building_type: building_type, level: 1, status: "operational") } + + it "returns the correct effect for the current level" do + expect(building.current_effect("tax_modifier")).to eq(0.05) + end + + it "returns nil if the building is under construction" do + building = build(:building, :under_construction, building_type: building_type) + expect(building.current_effect("tax_modifier")).to be_nil + end + + it "returns nil if the building is being demolished" do + building = build(:building, :being_demolished, building_type: building_type) + expect(building.current_effect("tax_modifier")).to be_nil + end + + + end + + describe "uniqueness validation" do + let(:building_type) { create(:building_type, unique_per_system: true) } + let(:star_system) { create(:star_system) } + + it "prevents creating duplicate buildings of unique type in same system" do + create(:building, building_type: building_type, star_system: star_system) + + duplicate = build(:building, building_type: building_type, star_system: star_system) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:building_type_id]).to include("already exists in this system") + end + + it "allows creating of buildings of unique type in different systems" do + create(:building, building_type: building_type, star_system: create(:star_system)) + + another_system = create(:star_system) + another_building = build(:building, building_type: building_type, star_system: another_system) + + expect(another_building).to be_valid + end + + it "allows creating buildings of non-unique type in same system" do + non_unique_type = create(:building_type, unique_per_system: false) + + create(:building, building_type: non_unique_type, star_system: star_system) + duplicate = build(:building, building_type: non_unique_type, star_system: star_system) + + expect(duplicate).to be_valid + end + end +end From 7f57c4ca5257d8d39685f79456ffd569d9bc6ddd Mon Sep 17 00:00:00 2001 From: Michael Dao Date: Wed, 21 May 2025 15:33:25 -0400 Subject: [PATCH 3/8] Refactor Building model validations and methods Update the level validation to ensure only integer values greater than zero are accepted. Modify the current_effect method to retrieve effects based on the building's level instead of a direct multiplication. Adjust related tests to reflect the change in error messaging for unique building types within star systems. --- app/models/building.rb | 4 ++-- spec/models/building_spec.rb | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/app/models/building.rb b/app/models/building.rb index 09cef88..2aa0c8e 100644 --- a/app/models/building.rb +++ b/app/models/building.rb @@ -6,7 +6,7 @@ class Building < ApplicationRecord STATUSES = %w[under_construction being_demolished operational].freeze # Validations - validates :level, presence: true, numericality: { greater_than: 0 } + validates :level, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :status, presence: true, inclusion: { in: STATUSES } validate :unique_per_system, if: -> { building_type&.unique_per_system? } @@ -50,7 +50,7 @@ def demolition_progress_percentage def current_effect(effect_key) return nil if under_construction? || being_demolished? - building_type.effects[effect_key] * level + building_type.effects_for_level(level)[effect_key] end private diff --git a/spec/models/building_spec.rb b/spec/models/building_spec.rb index 19e851e..29eba37 100644 --- a/spec/models/building_spec.rb +++ b/spec/models/building_spec.rb @@ -62,11 +62,6 @@ end describe "#construction_progress_percentage" do - it "returns 0 if construction has not started" do - building = build(:building, construction_start: nil, construction_end: nil) - expect(building.construction_progress_percentage).to eq(0) - end - it "returns 100 if construction is complete" do building = build(:building, status: "operational") expect(building.construction_progress_percentage).to eq(100) @@ -138,7 +133,7 @@ duplicate = build(:building, building_type: building_type, star_system: star_system) expect(duplicate).not_to be_valid - expect(duplicate.errors[:building_type_id]).to include("already exists in this system") + expect(duplicate.errors[:building_type_id]).to include("already exists in this star system") end it "allows creating of buildings of unique type in different systems" do From 4ee1ced2e4c5a1557ce97476ab4edee07017b586 Mon Sep 17 00:00:00 2001 From: Michael Dao Date: Wed, 21 May 2025 16:06:16 -0400 Subject: [PATCH 4/8] Implement tax income calculation in MaintenanceJob and enhance StarSystem model Refactor the tax revenue calculation in MaintenanceJob to sum tax income from each star system using the new calculate_tax_income method. Add methods for calculating tax income and tax modifiers based on operational buildings in the StarSystem model. Update tests to validate the new functionality and ensure accurate tax calculations. --- app/jobs/maintenance_job.rb | 6 ++-- app/models/star_system.rb | 21 ++++++++++++ spec/models/star_system_spec.rb | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/jobs/maintenance_job.rb b/app/jobs/maintenance_job.rb index 2e86033..7bb96c9 100644 --- a/app/jobs/maintenance_job.rb +++ b/app/jobs/maintenance_job.rb @@ -21,7 +21,9 @@ def maintenance_tasks end def tax_revenue - total_population = @empire.star_systems.sum(:current_population) - (total_population * @empire.tax_rate / 100).floor + # Calculate tax income for each star system and sum them up + @empire.star_systems.sum do |system| + system.calculate_tax_income + end end end diff --git a/app/models/star_system.rb b/app/models/star_system.rb index 22d38d7..57fc34c 100644 --- a/app/models/star_system.rb +++ b/app/models/star_system.rb @@ -1,5 +1,6 @@ class StarSystem < ApplicationRecord belongs_to :empire + has_many :buildings, dependent: :destroy SYSTEM_TYPES = %w[terrestrial ocean desert tundra gas_giant asteroid_belt] @@ -44,4 +45,24 @@ def new_population new_population = [new_population, 1].max # Can't go below 1 [new_population, max_population].min # Can't exceed max end + + def buildings_count + buildings.where(status: "operational").count + end + + def tax_modifier_from_buildings + buildings.where(status: "operational").sum do |building| + building.current_effect("tax_modifier") + end + end + + def calculate_tax_income + base_tax = (current_population * empire.tax_rate / 100.0).floor + + modifier = tax_modifier_from_buildings + + additional_tax = (base_tax * modifier).floor + + base_tax + additional_tax + end end diff --git a/spec/models/star_system_spec.rb b/spec/models/star_system_spec.rb index 4779854..b2634e8 100644 --- a/spec/models/star_system_spec.rb +++ b/spec/models/star_system_spec.rb @@ -3,6 +3,7 @@ RSpec.describe StarSystem, type: :model do # Association tests it { should belong_to(:empire) } + it { should have_many(:buildings) } # Validation tests it { should validate_presence_of(:name) } @@ -116,4 +117,62 @@ expect(system.new_population).to eq(1) end end + + describe "#buildings_count" do + let(:system) { create(:star_system) } + let(:building_type) { create(:building_type, unique_per_system: false) } + + it "returns the number of operational buildings" do + create(:building, star_system: system, status: "operational", building_type: building_type) + create(:building, star_system: system, status: "operational", building_type: building_type) + create(:building, :under_construction, star_system: system, building_type: building_type) + + expect(system.buildings_count).to eq(2) + end + end + + describe "tax_modifier from buildings" do + let(:system) { build(:star_system) } + let(:building_type) { create(:building_type) } + + it "returns 0 when there are no buildings" do + expect(system.tax_modifier_from_buildings).to eq(0) + end + + it "calculates tax modifier from operational buildings" do + create(:building, + star_system: system, + status: "operational", + building_type: building_type, + level: 1) + + expect(system.tax_modifier_from_buildings).to eq(0.05) + end + + it "ignores buildings under construction" do + create(:building, :under_construction, + star_system: system, + building_type: building_type, + level: 1) + + expect(system.tax_modifier_from_buildings).to eq(0) + end + + it "ignores buildings being demolished" do + create(:building, :being_demolished, + star_system: system, + building_type: building_type, + level: 1) + + expect(system.tax_modifier_from_buildings).to eq(0) + end + + it "calculates tax modifier from multiple buildings" do + building_type_2 = create(:building_type, key: "tax_office", level_data: { "1" => { effects: { tax_modifier: 0.03 }}}) + create(:building, star_system: system, status: "operational", building_type: building_type, level: 1) + create(:building, star_system: system, status: "operational", building_type: building_type_2, level: 1) + + expect(system.tax_modifier_from_buildings).to eq(0.08) + end + end end From 431eabd5d0440d5eb64c43e7104ffe1f902acb23 Mon Sep 17 00:00:00 2001 From: Michael Dao Date: Wed, 18 Jun 2025 20:05:59 -0400 Subject: [PATCH 5/8] Update Ruby version to 3.4.4 and upgrade dependencies in Gemfile and Gemfile.lock; enhance MaintenanceJob to manage building statuses during maintenance tasks with corresponding tests. --- .ruby-version | 2 +- Gemfile | 2 +- Gemfile.lock | 87 ++++++++++++++++--------------- app/jobs/maintenance_job.rb | 19 +++++++ spec/jobs/maintenance_job_spec.rb | 49 +++++++++++++++++ 5 files changed, 115 insertions(+), 44 deletions(-) diff --git a/.ruby-version b/.ruby-version index be94e6f..f989260 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.4.4 diff --git a/Gemfile b/Gemfile index 4b7cfb2..aa06c22 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -ruby "3.2.2" +ruby "3.4.4" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 7.1.5", ">= 7.1.5.1" diff --git a/Gemfile.lock b/Gemfile.lock index 915a094..dd587b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,12 +80,12 @@ GEM tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.0) - bigdecimal (3.1.9) + benchmark (0.4.1) + bigdecimal (3.2.2) bindex (0.8.1) - bootsnap (1.18.4) + bootsnap (1.18.6) msgpack (~> 1.2) builder (3.3.0) capybara (3.40.0) @@ -101,23 +101,24 @@ GEM logger (~> 1.5) coderay (1.1.3) concurrent-ruby (1.3.5) - connection_pool (2.5.2) + connection_pool (2.5.3) crass (1.0.6) date (3.4.1) - debug (1.10.0) + debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) - diff-lcs (1.6.1) + diff-lcs (1.6.2) docile (1.4.1) - drb (2.2.1) + drb (2.2.3) + erb (5.0.1) erubi (1.13.1) et-orbi (1.2.11) tzinfo - factory_bot (6.5.1) + factory_bot (6.5.4) activesupport (>= 6.1.0) - factory_bot_rails (6.4.4) + factory_bot_rails (6.5.0) factory_bot (~> 6.5) - railties (>= 5.0.0) + railties (>= 6.1.0) faker (3.5.1) i18n (>= 1.8.11, < 2) fugit (1.11.1) @@ -139,13 +140,13 @@ GEM jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.12.0) + json (2.12.2) launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) logger (~> 1.6) logger (1.7.0) - loofah (2.24.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -154,13 +155,13 @@ GEM net-pop net-smtp marcel (1.0.4) - matrix (0.4.2) + matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.5) msgpack (1.8.0) mutex_m (0.3.0) - net-imap (0.5.7) + net-imap (0.5.8) date net-protocol net-pop (0.1.2) @@ -192,16 +193,16 @@ GEM pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.2.3) + psych (5.2.6) date stringio - public_suffix (6.0.1) + public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.13) - rack-session (2.1.0) + rack (3.1.16) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -222,7 +223,7 @@ GEM activesupport (= 7.1.5.1) bundler (>= 1.15.0) railties (= 7.1.5.1) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -237,22 +238,23 @@ GEM rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) - rake (13.2.1) - rdoc (6.13.1) + rake (13.3.0) + rdoc (6.14.1) + erb psych (>= 4.0.0) redis (5.4.0) redis-client (>= 0.22.0) - redis-client (0.24.0) + redis-client (0.25.0) connection_pool regexp_parser (2.10.0) reline (0.6.1) io-console (~> 0.5) - rspec-core (3.13.3) + rspec-core (3.13.4) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) + rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (6.1.5) @@ -263,19 +265,19 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.2) + rspec-support (3.13.4) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) securerandom (0.4.1) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (8.0.3) + sidekiq (8.0.4) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) rack (>= 3.1.0) redis-client (>= 0.23.2) - sidekiq-scheduler (6.0.0) + sidekiq-scheduler (6.0.1) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) simplecov (0.22.0) @@ -292,14 +294,14 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (2.6.0-aarch64-linux-gnu) - sqlite3 (2.6.0-aarch64-linux-musl) - sqlite3 (2.6.0-arm-linux-gnu) - sqlite3 (2.6.0-arm-linux-musl) - sqlite3 (2.6.0-arm64-darwin) - sqlite3 (2.6.0-x86_64-darwin) - sqlite3 (2.6.0-x86_64-linux-gnu) - sqlite3 (2.6.0-x86_64-linux-musl) + sqlite3 (2.7.0-aarch64-linux-gnu) + sqlite3 (2.7.0-aarch64-linux-musl) + sqlite3 (2.7.0-arm-linux-gnu) + sqlite3 (2.7.0-arm-linux-musl) + sqlite3 (2.7.0-arm64-darwin) + sqlite3 (2.7.0-x86_64-darwin) + sqlite3 (2.7.0-x86_64-linux-gnu) + sqlite3 (2.7.0-x86_64-linux-musl) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) @@ -315,7 +317,7 @@ GEM railties (>= 7.0.0) thor (1.3.2) timeout (0.4.3) - turbo-rails (2.0.13) + turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) @@ -325,18 +327,19 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - websocket-driver (0.7.7) + websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.2) + zeitwerk (2.7.3) PLATFORMS aarch64-linux aarch64-linux-gnu aarch64-linux-musl + arm-linux arm-linux-gnu arm-linux-musl arm64-darwin @@ -373,7 +376,7 @@ DEPENDENCIES web-console RUBY VERSION - ruby 3.2.2p53 + ruby 3.4.4p34 BUNDLED WITH - 2.6.4 + 2.6.9 diff --git a/app/jobs/maintenance_job.rb b/app/jobs/maintenance_job.rb index 7bb96c9..b2f0366 100644 --- a/app/jobs/maintenance_job.rb +++ b/app/jobs/maintenance_job.rb @@ -12,14 +12,33 @@ def perform(empire_id) def maintenance_tasks ActiveRecord::Base.transaction do + # handle building status updates + update_building_statuses + + # collect tax revenue @empire.update(credits: @empire.credits + tax_revenue) if tax_revenue > 0 + # update population @empire.star_systems.each do |system| system.update(current_population: system.new_population) end end end + def update_building_statuses + @empire.star_systems.each do |system| + # Find buildings where construction has finished + system.buildings.where(status: "under_construction") + .where("construction_end <= ?", Time.current) + .update_all(status: "operational") + + # Remove buildings where demolition has finished + system.buildings.where(status: "being_demolished") + .where("demolition_end <= ?", Time.current) + .destroy_all + end + end + def tax_revenue # Calculate tax income for each star system and sum them up @empire.star_systems.sum do |system| diff --git a/spec/jobs/maintenance_job_spec.rb b/spec/jobs/maintenance_job_spec.rb index 4f3e820..f300e48 100644 --- a/spec/jobs/maintenance_job_spec.rb +++ b/spec/jobs/maintenance_job_spec.rb @@ -7,6 +7,7 @@ let!(:empire) { create(:empire, user: user, credits: 1000, tax_rate: 10) } let!(:star_system) { create(:star_system, empire: empire, current_population: 500) } let!(:star_system_2) { create(:star_system, empire: empire, current_population: 300) } + let!(:building_type) { create(:building_type) } describe "#perform" do it 'collects taxes based on population and tax rate' do @@ -35,4 +36,52 @@ }.to change { system.reload.current_population } end end + + describe "handling building status updates" do + let(:user) { create(:user) } + let(:empire) { create(:empire, user: user) } + let(:star_system) { create(:star_system, empire: empire) } + let(:building_type) { create(:building_type) } + + it "completes buildings whose construction time has passed" do + building = create(:building, + star_system: star_system, + building_type: building_type, + construction_end: 1.minute.ago) + + MaintenanceJob.perform_now(empire.id) + + expect(building.reload.status).to eq("operational") + end + + it "doesn't complete buildings that haven't finished construction" do + building = create(:building, :under_construction, + star_system: star_system, + building_type: building_type, + construction_end: 1.hour.from_now) + + MaintenanceJob.perform_now(empire.id) + + expect(building.reload.status).to eq("under_construction") + end + + it "removes buildings that have been demolished" do + building = create(:building, :being_demolished, + star_system: star_system, + building_type: building_type, + demolition_end: 1.minute.ago) + + expect { MaintenanceJob.perform_now(empire.id) }.to change { Building.count }.by(-1) + end + + it "doesn't remove buildings still being demolished" do + building = create(:building, :being_demolished, + star_system: star_system, + building_type: building_type, + demolition_end: 1.hour.from_now) + + expect { MaintenanceJob.perform_now(empire.id) }.not_to change { Building.count } + expect(building.reload.status).to eq("being_demolished") + end + end end From 2adb0553e7f2345a575680751963c03e796dda03 Mon Sep 17 00:00:00 2001 From: Michael Dao Date: Wed, 18 Jun 2025 21:29:38 -0400 Subject: [PATCH 6/8] Add building construction and demolition functionality Implement BuildingsController to handle building creation and destruction with necessary authorization checks. Update routes to include buildings resource. Enhance star system edit view to display available buildings, their costs, and construction progress. Add modal for confirming building construction. Update Gemfile to include selenium-webdriver for testing. Add feature tests for building construction and demolition scenarios, ensuring proper resource management and user feedback. --- Gemfile | 1 + Gemfile.lock | 15 + app/controllers/buildings_controller.rb | 87 +++++ app/views/star_systems/edit.html.erb | 312 +++++++++++++++++- config/routes.rb | 1 + db/seeds.rb | 114 +++++++ .../buildings/building_construction_spec.rb | 179 ++++++++++ 7 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 app/controllers/buildings_controller.rb create mode 100644 spec/features/buildings/building_construction_spec.rb diff --git a/Gemfile b/Gemfile index aa06c22..230d1ab 100644 --- a/Gemfile +++ b/Gemfile @@ -66,6 +66,7 @@ group :test do gem "capybara", "~> 3.39" gem "simplecov", require: false gem "launchy" + gem "selenium-webdriver" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index dd587b1..464c6ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,6 +103,10 @@ GEM concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) + database_cleaner-active_record (2.2.1) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) date (3.4.1) debug (1.11.0) irb (~> 1.10) @@ -249,6 +253,7 @@ GEM regexp_parser (2.10.0) reline (0.6.1) io-console (~> 0.5) + rexml (3.4.1) rspec-core (3.13.4) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -266,9 +271,16 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.4) + rubyzip (2.4.1) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) securerandom (0.4.1) + selenium-webdriver (4.33.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) sidekiq (8.0.4) @@ -327,6 +339,7 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + websocket (1.2.11) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -352,6 +365,7 @@ DEPENDENCIES bcrypt (~> 3.1.7) bootsnap capybara (~> 3.39) + database_cleaner-active_record debug factory_bot_rails (~> 6.2) faker (~> 3.2) @@ -363,6 +377,7 @@ DEPENDENCIES rails (~> 7.1.5, >= 7.1.5.1) redis (>= 4.0.1) rspec-rails (~> 6.0) + selenium-webdriver shoulda-matchers (~> 5.3) sidekiq sidekiq-scheduler diff --git a/app/controllers/buildings_controller.rb b/app/controllers/buildings_controller.rb new file mode 100644 index 0000000..0e58f3c --- /dev/null +++ b/app/controllers/buildings_controller.rb @@ -0,0 +1,87 @@ +class BuildingsController < ApplicationController + before_action :set_building, only: [:destroy] + before_action :authorize_building, only: [:destroy] + + + def create + Rails.logger.debug "BuildingsController#create called with params: #{params.inspect}" + + star_system = StarSystem.find(params[:building][:star_system_id]) + building_type = BuildingType.find(params[:building][:building_type_id]) + + # check authorization + unless star_system.empire == current_user.empire + redirect_to root_path, alert: "You do not have permission to build in this star system." + return + end + + # check if at max buildings + if star_system.buildings.where(status: ["operational", "under_construction"]).count >= star_system.max_buildings + redirect_to edit_star_system_path(star_system), alert: "Maximum buildings reached" + return + end + + # check if unique building already exists + if building_type.unique_per_system && star_system.buildings.where(building_type: building_type).exists? + redirect_to edit_star_system_path(star_system), alert: "This building type already exists in this star system" + return + end + + # check resources + cost = building_type.cost_for_level(1) + empire = current_user.empire + + if empire.credits < cost["credits"] || empire.minerals < cost["minerals"] || empire.energy < cost["energy"] + redirect_to edit_star_system_path(star_system), alert: "Insufficient Resources" + return + end + + # all checks pass, good to go + construction_time = building_type.construction_time_for_level(1) + + ActiveRecord::Base.transaction do + # Deduct resources + empire.update!( + credits: empire.credits - cost["credits"], + minerals: empire.minerals - cost["minerals"], + energy: empire.energy - cost["energy"] + ) + + # Create building + building = Building.create!( + star_system: star_system, + building_type: building_type, + level: 1, + status: "under_construction", + construction_start: Time.current, + construction_end: Time.current + construction_time + ) + end + + redirect_to edit_star_system_path(star_system), notice: "Building construction started" + + rescue => e + redirect_to edit_star_system_path(@star_system), alert: "Error starting construction: #{e.message}" + end + + def destroy + @building.update( + status: "being_demolished", + demolition_end: Time.current + @building.building_type.demolition_time_for_level(@building.level) + ) + + redirect_to edit_star_system_path(@building.star_system), notice: "Building demolition started" + end + + + private + def set_building + @building = Building.find(params[:id]) + end + + def authorize_building + unless @building.star_system.empire == current_user.empire + redirect_to root_path, alert: "You do not have permission to demolish this building" + end + end +end diff --git a/app/views/star_systems/edit.html.erb b/app/views/star_systems/edit.html.erb index f490a4f..8f4adcf 100644 --- a/app/views/star_systems/edit.html.erb +++ b/app/views/star_systems/edit.html.erb @@ -1,4 +1,4 @@ -
+

Administer Star System

<% if @star_system.errors.any? %> @@ -12,15 +12,311 @@
<% end %> - <%= form_with(model: @star_system, local: true, class: "space-y-6") do |form| %> + +
+ <%= form_with(model: @star_system, local: true, class: "space-y-6") do |form| %> +
+ <%= form.label :name, class: "block text-gray-300 mb-1" %> + <%= form.text_field :name, class: "w-full bg-space-blue/30 border border-space-purple/50 rounded-lg p-2 text-white" %> +
+ +
+ <%= form.submit "Update Star System", class: "bg-space-nebula hover:bg-space-purple text-white font-medium py-2 px-4 rounded-lg transition" %> + <%= link_to "Back to Dashboard", dashboard_path, class: "bg-transparent border border-space-purple/50 text-space-highlight hover:bg-space-blue/30 py-2 px-4 rounded-lg transition" %> +
+ <% end %> +
+ + +
+

Empire Resources

+
+
+ Credits: + <%= current_user.empire.credits %> +
+
+ Minerals: + <%= current_user.empire.minerals %> +
+
+ Energy: + <%= current_user.empire.energy %> +
+
+ Food: + <%= current_user.empire.food %> +
+
+
+ + +
+

Buildings

+

Buildings: <%= @star_system.buildings.where(status: ["operational", "under_construction"]).count %> / <%= @star_system.max_buildings %>

+ + + <% operational_buildings = @star_system.buildings.where(status: "operational") %> + <% if operational_buildings.any? %> +
+

Operational Buildings

+
+ <% operational_buildings.each do |building| %> +
+
+
<%= building.building_type.name %> (Level <%= building.level %>)
+ + +
+
+ +

<%= building.building_type.description %>

+ + <% if building.current_effect(:tax_modifier) %> +

Tax revenue: +<%= (building.current_effect(:tax_modifier) * 100).to_i %>%

+ <% end %> + + +
+
+
+ Operational +
+ + <%= button_to "Demolish", building_path(building), method: :delete, + data: { turbo_confirm: "Are you sure you want to demolish this building? This will take 1 hour and resources will NOT be refunded." }, + form_class: "inline", + class: "text-xs bg-red-900/50 hover:bg-red-900 text-red-300 px-2 py-1 rounded" %> +
+
+ <% end %> +
+
+ <% end %> + + + <% under_construction = @star_system.buildings.where(status: "under_construction") %> + <% if under_construction.any? %> +
+

Buildings Under Construction

+
+ <% under_construction.each do |building| %> +
+
+
<%= building.building_type.name %> (Level <%= building.level %>)
+ + +
+
+ +

<%= building.building_type.description %>

+ + +
+
Construction Progress: <%= building.construction_progress_percentage %>%
+
+
+
+
+ +
+ Completes at: <%= building.construction_end.strftime("%B %d, %H:%M") %> +
+
+ <% end %> +
+
+ <% end %> + + + <% being_demolished = @star_system.buildings.where(status: "being_demolished") %> + <% if being_demolished.any? %> +
+

Buildings Being Demolished

+
+ <% being_demolished.each do |building| %> +
+
+
<%= building.building_type.name %> (Level <%= building.level %>)
+ + +
+
+ + +
+
Demolition Progress: <%= building.demolition_progress_percentage %>%
+
+
+
+
+ +
+ Completes at: <%= building.demolition_end.strftime("%B %d, %H:%M") %> +
+
+ <% end %> +
+
+ <% end %> + +
- <%= form.label :name, class: "block text-gray-300 mb-1" %> - <%= form.text_field :name, class: "w-full bg-space-blue/30 border border-space-purple/50 rounded-lg p-2 text-white" %> +

Available Buildings

+
+ <% BuildingType.all.each do |building_type| %> +
+
+
<%= building_type.name %>
+ + +
+
+ +

<%= building_type.description %>

+ +
+ Cost: <%= building_type.cost_for_level(1)["credits"] %> Credits, + <%= building_type.cost_for_level(1)["minerals"] %> Minerals, + <%= building_type.cost_for_level(1)["energy"] %> Energy +
+ +
+ Construction time: <%= (building_type.construction_time_for_level(1) / 1.hour).round %> hours +
+ +
+ <% + can_build = true + error_message = nil + + # Check if we're at max buildings + if @star_system.buildings.where(status: ["operational", "under_construction"]).count >= @star_system.max_buildings + can_build = false + error_message = "Maximum buildings reached" + end + + # Check if unique building already exists + if building_type.unique_per_system && @star_system.buildings.where(building_type: building_type).exists? + can_build = false + error_message = "Already built" + end + + # Check resources + cost = building_type.cost_for_level(1) + empire = current_user.empire + + if empire.credits < cost["credits"] || empire.minerals < cost["minerals"] || empire.energy < cost["energy"] + can_build = false + error_message = "Insufficient resources" + end + %> + + <% if can_build %> + <%= form_with(model: Building.new, local: true) do |form| %> + <%= form.hidden_field :building_type_id, value: building_type.id %> + <%= form.hidden_field :star_system_id, value: @star_system.id %> + + + + + <%= form.submit "Confirm Construction", + id: "confirm-build-#{building_type.id}", + style: (test_env? ? "" : "display: none;"), + class: "w-full bg-space-nebula hover:bg-space-purple text-white text-sm font-medium py-2 px-4 rounded-lg transition" %> + <% end %> + <% else %> + + <% end %> +
+
+ <% end %> +
+
+
+
+ + + -