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..b5652f6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,36 +1,38 @@ -source "https://rubygems.org" +# frozen_string_literal: true -ruby "3.2.2" +source 'https://rubygems.org' + +ruby '3.4.4' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 7.1.5", ">= 7.1.5.1" +gem 'rails', '~> 7.1.5', '>= 7.1.5.1' # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] -gem "sprockets-rails" +gem 'sprockets-rails' # Use sqlite3 as the database for Active Record -gem "sqlite3", ">= 1.4" +gem 'sqlite3', '>= 1.4' # Use the Puma web server [https://github.com/puma/puma] -gem "puma", ">= 5.0" +gem 'puma', '>= 5.0' # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] -gem "importmap-rails" +gem 'importmap-rails' # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] -gem "turbo-rails" +gem 'turbo-rails' # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] -gem "stimulus-rails" +gem 'stimulus-rails' # Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] -gem "tailwindcss-rails", "~> 2.3" +gem 'tailwindcss-rails', '~> 2.3' # Build JSON APIs with ease [https://github.com/rails/jbuilder] -gem "jbuilder" +gem 'jbuilder' # Use Redis adapter to run Action Cable in production -gem "redis", ">= 4.0.1" +gem 'redis', '>= 4.0.1' # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" @@ -39,38 +41,39 @@ gem "redis", ">= 4.0.1" # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[ windows jruby ] +gem 'tzinfo-data', platforms: %i[windows jruby] # Reduces boot times through caching; required in config/boot.rb -gem "bootsnap", require: false +gem 'bootsnap', require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" -gem "bcrypt", "~> 3.1.7" +gem 'bcrypt', '~> 3.1.7' gem 'sidekiq' gem 'sidekiq-scheduler' group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[ mri windows ] - gem "rspec-rails", "~> 6.0" - gem "factory_bot_rails", "~> 6.2" - gem "faker", "~> 3.2" - gem "pry" + gem 'debug', platforms: %i[mri windows] + gem 'factory_bot_rails', '~> 6.2' + gem 'faker', '~> 3.2' + gem 'pry' + gem 'rspec-rails', '~> 6.0' end group :test do - gem "shoulda-matchers", "~> 5.3" - gem "capybara", "~> 3.39" - gem "simplecov", require: false - gem "launchy" + gem 'capybara', '~> 3.39' + gem 'launchy' + gem 'selenium-webdriver' + gem 'shoulda-matchers', '~> 5.3' + gem 'simplecov', require: false end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] - gem "web-console" + gem 'web-console' # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] # gem "rack-mini-profiler" @@ -78,4 +81,3 @@ group :development do # Speed up commands on slow machines / big apps [https://github.com/rails/spring] # gem "spring" end - diff --git a/Gemfile.lock b/Gemfile.lock index 915a094..e78a835 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,24 @@ 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) + rexml (3.4.1) + 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 +266,26 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.2) + 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.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 +302,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 +325,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 +335,20 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - websocket-driver (0.7.7) + websocket (1.2.11) + 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 @@ -360,6 +372,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 @@ -373,7 +386,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/README.md b/README.md index d7f4b9b..c7a9f6d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Key features include: - **Population Growth**: Watch your empire grow based on your tax policies and system types - **Economic Strategy**: Balance taxation and population growth to maximize your empire's potential - **Scheduled Maintenance**: Experience daily empire maintenance cycles that update your resources +- **Building System**: Construct, upgrade, and demolish buildings to enhance your star systems ## Technical Details @@ -22,13 +23,15 @@ Key features include: - **Authentication**: Custom implementation using BCrypt (no Devise) - **Background Jobs**: Sidekiq with sidekiq-scheduler for maintenance tasks - **Ruby Version**: 3.2.2 +- **Procfile**: Uses Foreman/bin/dev to run Rails, Sidekiq, and TailwindCSS concurrently ## Setup Instructions ### Prerequisites -- Ruby 3.2.2 -- Redis (for Sidekiq) -- Node.js and Yarn (for TailwindCSS) +- Ruby 3.4.4 +- Redis 8.0.2 (for Sidekiq) +- Node.js 22.15.0 +- Yarn 1.22.19 (for TailwindCSS) ### Installation @@ -49,7 +52,21 @@ yarn install bin/rails db:create db:migrate ``` -4. Start the server, worker, and CSS compiler +4. Seed the database with building types and (optionally) a sample empire +```bash +bin/rails db:seed +``` + + - The seed file creates core building types. To see a sample building, ensure you have a user, an empire, and a star system. You can create these via the Rails console: +```ruby +# In rails console +y = User.create!(email: "test@example.com", password: "password") +e = Empire.create!(user: y, name: "Test Empire") +s = StarSystem.create!(empire: e, name: "Sol", system_type: "terrestrial") +``` + - Then re-run `bin/rails db:seed` to create a sample building in your star system. + +5. Start the server, worker, and CSS compiler ```bash bin/dev ``` @@ -68,6 +85,11 @@ Check test coverage with SimpleCov (results in coverage/ directory): COVERAGE=true bundle exec rspec ``` +#### Testing Notes +- Uses [DatabaseCleaner](https://github.com/DatabaseCleaner/database_cleaner) with truncation for feature tests to ensure database state is visible across Capybara and Rails processes. +- Feature tests for modals and JavaScript use Selenium with headless Chrome. Progressive enhancement ensures modals are accessible and testable in both JS and non-JS environments. +- Accessibility and progressive enhancement are prioritized for all UI features, including modals. + ## Game Mechanics ### Empire Management @@ -84,6 +106,14 @@ COVERAGE=true bundle exec rspec - Credits: Generated through taxation - Minerals, Energy, Food: Base resources for building and maintenance +### Building System +- Construct, upgrade, and demolish buildings in your star systems +- Each building type (e.g., Government Administration, Mining Facility, Power Plant, Research Laboratory) provides unique benefits +- Buildings have construction and demolition times, costs, and effects +- Some buildings are unique per system, others can be built multiple times +- Building status is updated during scheduled maintenance cycles +- Building construction and demolition use accessible, progressively enhanced modals for confirmation + ## Development Approach This project follows Test-Driven Development (TDD) principles: @@ -92,11 +122,11 @@ This project follows Test-Driven Development (TDD) principles: 3. Refactor for improved design The application is built with a clean, modular architecture: -- **Models**: Core domain objects (User, Empire, StarSystem) +- **Models**: Core domain objects (User, Empire, StarSystem, Building, BuildingType) - **Services**: Encapsulated business logic (EmpireBuilderService) - **Jobs**: Background processing (MaintenanceJob, ScheduleMaintenanceJob) - **Controllers**: Minimal request handling with business logic in services ## Project Status -Voidfront Realms Elite is currently under active development. Core gameplay systems including user authentication, empire management, and star system management are functional. Future updates will include ship building, research, exploration, and more advanced gameplay features. +Voidfront Realms Elite is currently under active development. Core gameplay systems including user authentication, empire management, star system management, and the building system are functional. Future updates will include ship building, research, exploration, and more advanced gameplay features. diff --git a/Rakefile b/Rakefile index 9a5ea73..488c551 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,8 @@ +# frozen_string_literal: true + # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -require_relative "config/application" +require_relative 'config/application' Rails.application.load_tasks diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb index d672697..9aec230 100644 --- a/app/channels/application_cable/channel.rb +++ b/app/channels/application_cable/channel.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ApplicationCable class Channel < ActionCable::Channel::Base end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442..8d6c2a1 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ApplicationCable class Connection < ActionCable::Connection::Base end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index beb0357..5779bea 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,26 +1,28 @@ +# frozen_string_literal: true + class ApplicationController < ActionController::Base before_action :require_login helper_method :current_user, :user_signed_in? - + private - - def current_user - @current_user ||= if session[:user_id] - User.find_by(id: session[:user_id]) - elsif cookies.encrypted[:user_id] - user = User.find_by(id: cookies.encrypted[:user_id]) - if user - session[:user_id] = user.id - user - end + + def current_user + @current_user ||= if session[:user_id] + User.find_by(id: session[:user_id]) + elsif cookies.encrypted[:user_id] + user = User.find_by(id: cookies.encrypted[:user_id]) + if user + session[:user_id] = user.id + user end - end - - def user_signed_in? - !current_user.nil? - end - - def require_login - redirect_to root_path, alert: "You must be logged in to access this page." unless user_signed_in? - end + end + end + + def user_signed_in? + !current_user.nil? + end + + def require_login + redirect_to root_path, alert: 'You must be logged in to access this page.' unless user_signed_in? + end end diff --git a/app/controllers/buildings_controller.rb b/app/controllers/buildings_controller.rb new file mode 100644 index 0000000..5bf137d --- /dev/null +++ b/app/controllers/buildings_controller.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +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: %w[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.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 StandardError => 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 + return if @building.star_system.empire == current_user.empire + + redirect_to root_path, alert: 'You do not have permission to demolish this building' + end +end diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 749f0b9..35fea22 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -1,4 +1,5 @@ +# frozen_string_literal: true + class DashboardsController < ApplicationController - def index - end -end \ No newline at end of file + def index; end +end diff --git a/app/controllers/empires_controller.rb b/app/controllers/empires_controller.rb index 63cdf14..0823ce6 100644 --- a/app/controllers/empires_controller.rb +++ b/app/controllers/empires_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmpiresController < ApplicationController def edit @empire = current_user.empire @@ -6,7 +8,7 @@ def edit def update @empire = current_user.empire if @empire.update(empire_params) - redirect_to dashboard_path, notice: "Empire updated successfully" + redirect_to dashboard_path, notice: 'Empire updated successfully' else render :edit, status: :unprocessable_entity end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 4ae9d0d..be4df2a 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + class HomeController < ApplicationController skip_before_action :require_login, only: [:index] - - def index - end -end \ No newline at end of file + + def index; end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f08b1c0..da03086 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,20 +1,19 @@ +# frozen_string_literal: true + class SessionsController < ApplicationController - skip_before_action :require_login, only: [:new, :create] - def new - end + skip_before_action :require_login, only: %i[new create] + def new; end def create user = User.find_by(email: params[:email]) - if user && user.authenticate(params[:password]) + if user&.authenticate(params[:password]) session[:user_id] = user.id - if params[:remember_me] == '1' - cookies.permanent.encrypted[:user_id] = user.id - end - redirect_to root_path, notice: "Login Successful" + cookies.permanent.encrypted[:user_id] = user.id if params[:remember_me] == '1' + redirect_to root_path, notice: 'Login Successful' else - flash.now[:alert] = "Invalid email or password" + flash.now[:alert] = 'Invalid email or password' render :new, status: :unprocessable_entity end end @@ -22,6 +21,6 @@ def create def destroy session[:user_id] = nil cookies.permanent.encrypted[:user_id] = nil - redirect_to root_path, notice: "You have been logged out" + redirect_to root_path, notice: 'You have been logged out' end -end \ No newline at end of file +end diff --git a/app/controllers/star_systems_controller.rb b/app/controllers/star_systems_controller.rb index d5a2569..9862740 100644 --- a/app/controllers/star_systems_controller.rb +++ b/app/controllers/star_systems_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StarSystemsController < ApplicationController before_action :authorize_star_system @@ -8,7 +10,7 @@ def edit def update @star_system = StarSystem.find(params[:id]) if @star_system.update(star_system_params) - redirect_to edit_star_system_path(@star_system), notice: "Star system updated successfully" + redirect_to edit_star_system_path(@star_system), notice: 'Star system updated successfully' else render :edit, status: :unprocessable_entity end @@ -22,8 +24,8 @@ def star_system_params def authorize_star_system @star_system = StarSystem.find(params[:id]) - unless @star_system.empire == current_user.empire - redirect_to root_path, alert: "You do not have permission to administer this star system" - end + return if @star_system.empire == current_user.empire + + redirect_to root_path, alert: 'You do not have permission to administer this star system' end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ba0ab50..54c5ae8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + class UsersController < ApplicationController - skip_before_action :require_login, only: [:new, :create] + skip_before_action :require_login, only: %i[new create] def new @user = User.new end - + def create @user = User.new(user_params) - + if @user.save empire_name = params[:user][:empire_name].presence empire = EmpireBuilderService.new(@user).create_empire(empire_name) @@ -16,17 +18,17 @@ def create @user.errors.add(:empire_name, empire.errors.messages[:name].first) if empire.errors.messages[:name].present? return render :new, status: :unprocessable_entity end - + session[:user_id] = @user.id - redirect_to dashboard_path, notice: "Registration Successful" + redirect_to dashboard_path, notice: 'Registration Successful' else render :new, status: :unprocessable_entity end end - + private - + def user_params params.require(:user).permit(:email, :username, :password, :password_confirmation) end -end \ No newline at end of file +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..15b06f0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + module ApplicationHelper end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d..bef3959 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked diff --git a/app/jobs/maintenance_job.rb b/app/jobs/maintenance_job.rb index 2e86033..c1f8f5d 100644 --- a/app/jobs/maintenance_job.rb +++ b/app/jobs/maintenance_job.rb @@ -1,27 +1,48 @@ +# frozen_string_literal: true + class MaintenanceJob < ApplicationJob queue_as :default def perform(empire_id) @empire = Empire.find(empire_id) return unless @empire - - maintenance_tasks + + maintenance_tasks end private def maintenance_tasks ActiveRecord::Base.transaction do - @empire.update(credits: @empire.credits + tax_revenue) if tax_revenue > 0 + # handle building status updates + update_building_statuses + # collect tax revenue + @empire.update(credits: @empire.credits + tax_revenue) if tax_revenue.positive? + + # 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 - 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(&:calculate_tax_income) end end diff --git a/app/jobs/schedule_maintenance_job.rb b/app/jobs/schedule_maintenance_job.rb index 561e25f..3d7698f 100644 --- a/app/jobs/schedule_maintenance_job.rb +++ b/app/jobs/schedule_maintenance_job.rb @@ -1,13 +1,13 @@ +# frozen_string_literal: true + class ScheduleMaintenanceJob < ApplicationJob queue_as :default def perform Empire.find_each(batch_size: 100) do |empire| - begin - MaintenanceJob.perform_later(empire.id) - rescue StandardError => e - Rails.logger.error("Failed to schedule maintenance for Empire #{empire.id}: #{e.message}") - end + MaintenanceJob.perform_later(empire.id) + rescue StandardError => e + Rails.logger.error("Failed to schedule maintenance for Empire #{empire.id}: #{e.message}") end end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..d84cb6e 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,6 @@ +# frozen_string_literal: true + class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" - layout "mailer" + default from: 'from@example.com' + layout 'mailer' end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb..08dc537 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationRecord < ActiveRecord::Base primary_abstract_class end diff --git a/app/models/building.rb b/app/models/building.rb new file mode 100644 index 0000000..32bdde3 --- /dev/null +++ b/app/models/building.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +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: { only_integer: true, 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_for_level(level)[effect_key] + 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? + + return unless existing.exists? + + errors.add(:building_type_id, 'already exists in this star system') + end +end diff --git a/app/models/building_type.rb b/app/models/building_type.rb new file mode 100644 index 0000000..953d648 --- /dev/null +++ b/app/models/building_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +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/app/models/empire.rb b/app/models/empire.rb index 35548b4..7e5907d 100644 --- a/app/models/empire.rb +++ b/app/models/empire.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + class Empire < ApplicationRecord belongs_to :user has_many :star_systems, dependent: :destroy validates :name, presence: true, uniqueness: true - validates :credits, :minerals, :energy, :food, + validates :credits, :minerals, :energy, :food, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :tax_rate, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } - + def resources_summary "Credits: #{credits} | Minerals: #{minerals} | Energy: #{energy} | Food: #{food}" end diff --git a/app/models/star_system.rb b/app/models/star_system.rb index 22d38d7..4a3d031 100644 --- a/app/models/star_system.rb +++ b/app/models/star_system.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + class StarSystem < ApplicationRecord belongs_to :empire - - SYSTEM_TYPES = %w[terrestrial ocean desert tundra gas_giant asteroid_belt] - + has_many :buildings, dependent: :destroy + + SYSTEM_TYPES = %w[terrestrial ocean desert tundra gas_giant asteroid_belt].freeze + validates :name, presence: true validates :system_type, presence: true, inclusion: { in: SYSTEM_TYPES } validates :max_population, numericality: { only_integer: true, greater_than: 0 } @@ -12,17 +15,17 @@ class StarSystem < ApplicationRecord def base_growth_rate case system_type - when "terrestrial" + when 'terrestrial' 0.05 - when "ocean" + when 'ocean' 0.04 - when "tundra" + when 'tundra' 0.03 - when "desert" + when 'desert' 0.02 - when "gas_giant" + when 'gas_giant' 0.01 - when "asteroid_belt" + when 'asteroid_belt' 0.005 else 0.01 # Default fallback @@ -41,7 +44,27 @@ def calculate_growth def new_population new_population = current_population + calculate_growth - new_population = [new_population, 1].max # Can't go below 1 - [new_population, max_population].min # Can't exceed max + 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/app/models/user.rb b/app/models/user.rb index 629c56b..bc13308 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class User < ApplicationRecord has_secure_password has_one :empire, dependent: :destroy - + validates :email, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true -end \ No newline at end of file +end diff --git a/app/services/empire_builder_service.rb b/app/services/empire_builder_service.rb index a8208e3..7419fe9 100644 --- a/app/services/empire_builder_service.rb +++ b/app/services/empire_builder_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmpireBuilderService def initialize(user) @user = user @@ -12,40 +14,38 @@ def create_empire(name = nil) name: empire_name ) - unless empire.save - return empire - end + return empire unless empire.save create_starting_star_system(empire) empire end - rescue => e + rescue StandardError => e empire = Empire.new(user: @user) empire.errors.add(:name, e.message) empire end - - private - def generate_empire_name - 10.times do - name = "#{Faker::Adjective.positive}-#{Faker::Hipster.word}" - return name unless Empire.exists?(name: name) - end + private - "#{Faker::Adjective.positive}-#{Faker::Hipster.word}-#{Faker::Number.hexadecimal(digits: 4)}" + def generate_empire_name + 10.times do + name = "#{Faker::Adjective.positive}-#{Faker::Hipster.word}" + return name unless Empire.exists?(name: name) end - def create_starting_star_system(empire) - StarSystem.create!( + "#{Faker::Adjective.positive}-#{Faker::Hipster.word}-#{Faker::Number.hexadecimal(digits: 4)}" + end + + def create_starting_star_system(empire) + StarSystem.create!( name: Faker::Space.star, - system_type: "terrestrial", + system_type: 'terrestrial', max_population: 1000, current_population: 500, max_buildings: 10, loyalty: 100, empire: empire - ) - end -end \ No newline at end of file + ) + end +end diff --git a/app/views/star_systems/edit.html.erb b/app/views/star_systems/edit.html.erb index f490a4f..25cfc26 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: "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 %> +
+
+
+
+ + +