diff --git a/Gemfile b/Gemfile index e682c9b..0f5a327 100644 --- a/Gemfile +++ b/Gemfile @@ -32,3 +32,5 @@ group :test do gem "database_cleaner", "~> 2.0" gem "rspec", "~> 3.10" end + +gem "ostruct" diff --git a/Gemfile.lock b/Gemfile.lock index bbe6a3d..15eac80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,6 +91,7 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) + ostruct (0.6.3) parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) @@ -206,6 +207,7 @@ PLATFORMS DEPENDENCIES activerecord (~> 7.1) database_cleaner (~> 2.0) + ostruct pry (~> 0.14.1) rake (~> 13.0) require_all (~> 3.0) diff --git a/README.md b/README.md index d6d0893..99c5ec1 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,290 @@ -# Phase 3 Project — Single-Script Active Record CLI +# QuestCLI -## Learning Goals +QuestCLI is a Ruby command-line application built with **Active Record** and **SQLite3** that allows players to create an adventurer, accept quests, complete objectives, earn experience, and level up. -- Build a database-backed Ruby application using Active Record -- Design and interact with data models using object-oriented Ruby -- Implement a multi-class CLI frontend -- Practice working with migrations, associations, and validations +Designed as an RPG-inspired quest manager, QuestCLI demonstrates object-oriented programming principles, database design, Active Record associations, validations, and full CRUD functionality through an interactive command-line interface. -## Introduction +--- -Congrats on getting through all the material for Phase 3! You've learned how to work with databases, design models with Active Record, and write object-oriented Ruby. Now it's time to bring those skills together into a full project. +# Features -This project will focus on building a Ruby command-line application that reads from and writes to a local SQLite3 database using Active Record — no web server required. +## Adventurer Management -By the end of the project, you'll have a functioning CLI that lets users interact with your data by creating, viewing, updating, and deleting records from the terminal. +- Create adventurers +- View all adventurers +- View individual adventurer details +- Update adventurer information +- Delete adventurers -## Requirements +## Quest Management -### Models +- Accept new quests +- View all quests +- View active quests +- View completed quests +- Update quest information +- Complete quests +- Abandon quests -- At least two model classes using `ActiveRecord::Base` -- A one-to-many relationship (`has_many` / `belongs_to`) -- At least one model with validations -- Display associated data where appropriate (e.g. listing a parent record's associated children) +## Progression System -### CLI +- Earn experience by completing quests +- Automatic level progression +- Prevent duplicate quest rewards +- Track active and completed quests -- At least two Ruby classes (e.g. a `Menu` class and a model-specific helper) -- A loop or menu interface -- Ability to create, view, update, and delete records -- Update prompts should display the current value before asking for a new one +--- -## Planning +# Domain Model -- Plan out your features -- Develop user stories - - "As [ a user ], I want [ to perform this action ] so that [ I can accomplish this goal ]." - - Features should not need you there to explain them to users - - Create a `user-stories.md` file and add your user stories there +QuestCLI contains two Active Record models with a one-to-many relationship. -## Project Pitches +- A **Player** has many **Quests** +- A **Quest** belongs to one **Player** -Before you start working on your project, you'll pitch your project idea to your instructors for approval and feedback. +```text +Player +------ +id +name +level +current_xp -For your project pitch, you should include: + 1 + │ + │ has many + ▼ -- The basic story of your application -- The core features of your MVP -- The data you plan to persist and how you will structure it -- Challenges you expect to face -- How you are meeting the requirements of the project +Quest +------ +id +title +description +difficulty +xp_reward +completed +player_id +``` -**MVP ASAP** — Focus on getting your minimum viable product working first! +--- -## Example Project Domains +# Technologies -You could build a **Book Tracker** app: +- Ruby +- Active Record +- SQLite3 +- Rake -- `Author` has many `Books` -- Users can: - - Create a new book - - List all books - - Update book details - - Delete a book - - View books by a specific author +--- -Or a **Workout Log**: +# Installation -- `WorkoutSession` has many `Exercises` -- Users can: - - Log a new workout - - Add exercises - - Update reps/weights - - View or delete past workouts +Clone the repository. -## Getting Started +```bash +git clone +``` -**Fork and clone** this repository to get started. +Navigate into the project. -Install dependencies: +```bash +cd phase-3-ruby-project +``` + +Install dependencies. ```bash bundle install ``` -Create and migrate the database: +Create the database. ```bash bundle exec rake db:create +``` + +Run the migrations. + +```bash bundle exec rake db:migrate ``` -Optionally seed the database with starter data: +(Optional) Seed the database. ```bash bundle exec rake seed ``` -Run your CLI application: +Start the application. ```bash ruby cli/main.rb ``` -## Other Useful Commands +--- -Open a Pry console with your models loaded: +# Example Gameplay -```bash -bundle exec rake console +```text +======================== + QUESTCLI +======================== + +1. Manage Players +2. Manage Quests +3. View Quest Log +4. Exit ``` -Generate a new migration: +Accepting a quest: -```bash -bundle exec rake db:create_migration NAME=create_books -``` +```text +⚔ Quest Accepted! -## Project Structure +Quest: +Recover the Ancient Relic +Difficulty: +Medium + +Reward: +250 XP + +Status: +Active ``` -├── app/ -│ └── models/ # Your Active Record model classes go here -├── cli/ -│ └── main.rb # Entry point — your CLI menu lives here -├── config/ -│ └── environment.rb # Loads gems, DB connection, and models -├── db/ -│ ├── config.yml # Database connection settings -│ ├── migrate/ # Migration files -│ └── seeds.rb # Seed data -└── spec/ # RSpec tests (optional) + +Completing a quest: + +```text +🏆 Quest Complete! + ++250 XP + +Current XP: 950 + +LEVEL UP! + +You are now Level 4. ``` -## Project Tips +--- -- Sketch your domain model first using [dbdiagram.io](https://dbdiagram.io/) -- Use `bundle exec rake console` to test your models before building the CLI -- Use `binding.pry` for debugging -- Use `puts` and `pp` or gems like `tty-table` for formatted CLI output +# CRUD Functionality -## Sample Project +## Players -A complete implementation is available on the `sample-project` branch. It demonstrates: +### Create -- **Pet Tracker** domain with Owners and Pets -- Full CRUD with Active Record and a clean menu-driven CLI -- Object-oriented design with user-friendly output -- All required features including current value prompts for updates +Create a new adventurer. -To view the sample: +### Read -```bash -git checkout sample-project +View all adventurers or inspect an individual adventurer. + +### Update + +Modify an adventurer's information. + +### Delete + +Delete an adventurer and their associated quests. + +--- + +## Quests + +### Create + +Accept a new quest. + +### Read + +View all quests, active quests, completed quests, or quests belonging to a specific player. + +### Update + +Modify quest details. + +### Delete + +Abandon a quest. + +--- + +# Validations + +## Player + +- Name is required +- Name must be unique +- Level must be greater than zero +- Current experience cannot be negative + +## Quest + +- Title is required +- Difficulty is required +- Experience reward must be greater than zero +- Every quest must belong to a player + +--- + +# Project Structure + +```text +. +├── app +│ └── models +│ ├── player.rb +│ └── quest.rb +├── cli +│ └── main.rb +├── config +│ ├── database.yml +│ └── environment.rb +├── db +│ ├── migrate +│ └── seeds.rb +└── README.md ``` -See `SAMPLE_PROJECT_README.md` for detailed documentation. +--- + +# Future Improvements + +Future versions of QuestCLI may include: + +- Character classes +- Gold and economy system +- Inventory management +- Equipment +- NPC quest givers +- Random quest generation +- Quest categories +- Achievements +- Player statistics dashboard +- Multiple save files +- ASCII art and enhanced terminal styling + +--- + +# Learning Objectives + +This project demonstrates: + +- Object-Oriented Programming +- Active Record Associations +- Active Record Validations +- Database Migrations +- SQLite3 +- CRUD Operations +- Menu-Driven CLI Design +- Ruby Classes and Modules +- Separation of Concerns + +--- -## Resources +# Author -- [dbdiagram.io](https://dbdiagram.io/) -- [Active Record Basics](https://guides.rubyonrails.org/active_record_basics.html) -- [Active Record Associations](https://guides.rubyonrails.org/association_basics.html) -- [Active Record Validations](https://guides.rubyonrails.org/active_record_validations.html) +Thomas Correia diff --git a/Rakefile b/Rakefile index 0b7ef85..18ec232 100644 --- a/Rakefile +++ b/Rakefile @@ -4,11 +4,11 @@ require "standalone_migrations" StandaloneMigrations::Tasks.load_tasks desc "Start the console" -task :console => :environment do +task console: :environment do Pry.start end desc "Seed the database" -task :seed => :environment do +task seed: :environment do load "db/seeds.rb" -end \ No newline at end of file +end diff --git a/app/models/player.rb b/app/models/player.rb new file mode 100644 index 0000000..a807b48 --- /dev/null +++ b/app/models/player.rb @@ -0,0 +1,47 @@ +class Player < ActiveRecord::Base + BASE_XP_REQUIREMENT = 100 + + has_many :quests, dependent: :destroy + + validates :name, presence: true, uniqueness: true + + validates :level, + numericality: { + only_integer: true, + greater_than: 0, + } + + validates :current_xp, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0, + } + + def add_experience!(amount) + new_xp = current_xp + amount + new_level = calculate_level(new_xp) + + update!( + current_xp: new_xp, + level: new_level + ) + end + + private + + def calculate_level(experience) + calculated_level = 1 + remaining_xp = experience + + while remaining_xp >= xp_required_for_next_level(calculated_level) + remaining_xp -= xp_required_for_next_level(calculated_level) + calculated_level += 1 + end + + calculated_level + end + + def xp_required_for_next_level(current_level) + current_level * BASE_XP_REQUIREMENT + end +end diff --git a/app/models/quest.rb b/app/models/quest.rb new file mode 100644 index 0000000..4d341ea --- /dev/null +++ b/app/models/quest.rb @@ -0,0 +1,13 @@ +class Quest < ActiveRecord::Base + DIFFICULTIES = %w[Easy Medium Hard].freeze + + belongs_to :player + + validates :title, presence: true + validates :difficulty, presence: true, inclusion: { in: DIFFICULTIES } + validates :xp_reward, + numericality: { + only_integer: true, + greater_than: 0, + } +end diff --git a/cli/colors.rb b/cli/colors.rb new file mode 100644 index 0000000..c1b552f --- /dev/null +++ b/cli/colors.rb @@ -0,0 +1,103 @@ +module Colors + RESET = "\e[0m".freeze + BOLD = "\e[1m".freeze + + RED = "\e[31m".freeze + GREEN = "\e[32m".freeze + YELLOW = "\e[33m".freeze + BLUE = "\e[34m".freeze + MAGENTA = "\e[35m".freeze + CYAN = "\e[36m".freeze + + BRIGHT_GREEN = "\e[92m".freeze + BRIGHT_YELLOW = "\e[93m".freeze + BRIGHT_MAGENTA = "\e[95m".freeze + BRIGHT_CYAN = "\e[96m".freeze + + module_function + + def red(text) + colorize(text, RED) + end + + def green(text) + colorize(text, GREEN) + end + + def yellow(text) + colorize(text, YELLOW) + end + + def blue(text) + colorize(text, BLUE) + end + + def magenta(text) + colorize(text, MAGENTA) + end + + def cyan(text) + colorize(text, CYAN) + end + + def bright_green(text) + colorize(text, BRIGHT_GREEN) + end + + def bright_yellow(text) + colorize(text, BRIGHT_YELLOW) + end + + def bright_magenta(text) + colorize(text, BRIGHT_MAGENTA) + end + + def bright_cyan(text) + colorize(text, BRIGHT_CYAN) + end + + def bold(text) + colorize(text, BOLD) + end + + def heading(text) + bold(text) + end + + def success(text) + bright_green(text) + end + + def warning(text) + bright_yellow(text) + end + + def error(text) + red(text) + end + + def xp(text) + bright_magenta(text) + end + + def difficulty(value) + case value + when "Easy" + green(value) + when "Medium" + yellow(value) + when "Hard" + red(value) + else + value + end + end + + def status(completed) + completed ? bright_green("Completed") : yellow("Active") + end + + def colorize(text, color) + "#{color}#{text}#{RESET}" + end +end diff --git a/cli/main.rb b/cli/main.rb index 33e117f..43bbb86 100755 --- a/cli/main.rb +++ b/cli/main.rb @@ -2,19 +2,4 @@ require_relative "../config/environment" -# TODO: Build your CLI application here! -# -# Requirements: -# - Be object-oriented (at least two classes) -# - Query and update the database directly via Active Record models -# - Parse and display records in a readable format -# - Accept user input and use it to create, update, and delete records -# - Use a loop or menu interface -# - Include current value prompts for updates (e.g. show the current value -# before asking the user what they want to change it to) - -puts "Welcome to your CLI Application!" -puts - -# TODO: Implement your CLI functionality here -puts "TODO: Build your CLI application" +QuestCLI.new.run diff --git a/cli/player_menu.rb b/cli/player_menu.rb new file mode 100644 index 0000000..d612407 --- /dev/null +++ b/cli/player_menu.rb @@ -0,0 +1,200 @@ +require_relative "colors" + +class PlayerMenu + MENU_ACTIONS = { + "1" => :create_player, + "2" => :view_all_players, + "3" => :view_player_details, + "4" => :update_player, + "5" => :delete_player, + }.freeze + + def run + loop do + display_menu + choice = gets.chomp + + break if return_to_main_menu?(choice) + + perform_action(choice) + end + end + + private + + def display_menu + puts + puts "========================" + puts Colors.heading(" ADVENTURER MENU") + puts "========================" + puts "1. Create Adventurer" + puts "2. View All Adventurers" + puts "3. View Adventurer Details" + puts "4. Update Adventurer" + puts "5. Delete Adventurer" + puts "6. Return to Main Menu" + print "\n#{Colors.yellow('Choose an option: ')}" + end + + def return_to_main_menu?(choice) + return false unless choice == "6" + + puts "\n#{Colors.warning('Returning to the main menu...')}" + true + end + + def perform_action(choice) + action = MENU_ACTIONS[choice] + + if action + send(action) + else + puts "\n#{Colors.error('Invalid choice. Please select an option from 1 to 6.')}" + end + end + + def create_player + print "\nEnter adventurer name: " + player = Player.new(name: gets.chomp) + + if player.save + puts "\n#{Colors.success('Adventurer created successfully!')}" + display_player(player) + else + display_errors(player) + end + end + + def view_all_players + players = Player.all.to_a + + if players.empty? + puts "\n#{Colors.warning('No adventurers have been created yet.')}" + return + end + + display_heading("ADVENTURERS") + + players.each_with_index do |player, index| + puts "\n#{Colors.heading("#{index + 1}. #{player.name}")}" + display_player(player, show_name: false) + end + end + + def view_player_details + player = select_player + return unless player + + display_heading("ADVENTURER DETAILS") + display_player(player) + display_quest_summary(player) + end + + def update_player + player = select_player + return unless player + + print "\nName [#{player.name}]: " + new_name = gets.chomp + new_name = player.name if new_name.empty? + + if player.update(name: new_name) + puts "\n#{Colors.success('Adventurer updated successfully!')}" + display_player(player) + else + display_errors(player) + end + end + + def delete_player + player = select_player + return unless player + + puts "\n#{Colors.warning('You selected:')}" + display_player(player) + + unless confirm_action?( + "Delete this adventurer and all of their quests?" + ) + puts "\n#{Colors.warning('Deletion canceled.')}" + return + end + + destroy_player(player) + end + + def destroy_player(player) + if player.destroy + puts "\n#{Colors.success('Adventurer deleted successfully.')}" + else + display_errors(player) + end + end + + def confirm_action?(message) + print "\n#{Colors.warning("#{message} (y/n): ")}" + gets.chomp.downcase == "y" + end + + def select_player + players = Player.all.to_a + + if players.empty? + puts "\n#{Colors.warning('No adventurers have been created yet.')}" + return + end + + puts "\n#{Colors.heading('Select an adventurer:')}" + + players.each_with_index do |player, index| + puts "#{Colors.cyan(index + 1)}. #{player.name}" + end + + print "\nEnter adventurer number: " + selected_player = record_at_index(players, gets.chomp) + + puts "\n#{Colors.error('Adventurer not found.')}" unless selected_player + + selected_player + end + + def record_at_index(records, input) + index = input.to_i - 1 + + return if index.negative? + return if index >= records.length + + records[index] + end + + def display_heading(title) + puts + puts "========================" + puts Colors.heading(title.center(24)) + puts "========================" + end + + def display_player(player, show_name: true) + puts "\n#{Colors.bold('Name:')} #{player.name}" if show_name + puts "#{Colors.bold('Level:')} #{Colors.xp(player.level)}" + puts "#{Colors.bold('Current XP:')} #{Colors.xp(player.current_xp)}" + puts "------------------------" + end + + def display_quest_summary(player) + active_count = player.quests.where(completed: false).count + completed_count = player.quests.where(completed: true).count + + puts "#{Colors.yellow('Active Quests:')} #{active_count}" + puts "#{Colors.green('Completed Quests:')} #{completed_count}" + puts "------------------------" + end + + def display_errors(record) + puts "\n#{Colors.error('Unable to save adventurer:')}" + + record.errors.full_messages.each do |message| + puts Colors.red("- #{message}") + end + end +end diff --git a/cli/quest_cli.rb b/cli/quest_cli.rb new file mode 100644 index 0000000..223cbbe --- /dev/null +++ b/cli/quest_cli.rb @@ -0,0 +1,79 @@ +require_relative "colors" + +class QuestCLI + MENU_ACTIONS = { + "1" => :manage_adventurers, + "2" => :manage_quests, + "3" => :view_quest_log, + }.freeze + + def run + display_welcome + + loop do + display_main_menu + choice = gets.chomp + + break if exit_selected?(choice) + + perform_action(choice) + end + end + + private + + def display_welcome + puts + puts("========================") + puts Colors.heading(" QUESTCLI") + puts("========================") + puts "Create adventurers, accept quests, and earn experience!" + end + + def display_main_menu + puts + puts "========================" + puts Colors.heading(" MAIN MENU") + puts "========================" + puts "1. Manage Adventurers" + puts "2. Manage Quests" + puts "3. View Quest Log" + puts "4. Exit" + print "\n#{Colors.yellow('Choose an option: ')}" + end + + def exit_selected?(choice) + return false unless choice == "4" + + puts + puts "========================" + puts Colors.heading(" THANKS FOR PLAYING") + puts "========================" + puts Colors.success("Your adventures have been saved.") + true + end + + def perform_action(choice) + action = MENU_ACTIONS[choice] + + if action + send(action) + else + puts "\n#{Colors.error('Invalid choice. Please select an option from 1 to 4.')}" + end + end + + def manage_adventurers + PlayerMenu.new.run + end + + def manage_quests + QuestMenu.new.run + end + + def view_quest_log + players = Player.includes(:quests) + + QuestDisplay.new.quest_log(players) + end +end diff --git a/cli/quest_display.rb b/cli/quest_display.rb new file mode 100644 index 0000000..b77c0b8 --- /dev/null +++ b/cli/quest_display.rb @@ -0,0 +1,143 @@ +require_relative "colors" + +class QuestDisplay + def menu + puts + puts "========================" + puts Colors.heading(" QUEST MENU") + puts "========================" + puts "1. Accept Quest" + puts "2. View All Quests" + puts "3. View Adventurer's Quests" + puts "4. View Active Quests" + puts "5. View Completed Quests" + puts "6. Update Quest" + puts "7. Complete Quest" + puts "8. Abandon Quest" + puts "9. Return to Main Menu" + print "\n#{Colors.yellow('Choose an option: ')}" + end + + def quests(quests) + quests = quests.to_a + + if quests.empty? + puts "\n#{Colors.warning('No quests found.')}" + return + end + + quests.each_with_index do |quest, index| + quest_details(quest, index + 1) + end + end + + def quest_choices(quests) + puts + + quests.each_with_index do |quest, index| + number = Colors.cyan(index + 1) + + puts "#{number}. #{quest.title} - #{quest.player.name}" + end + end + + def quest_details(quest, number = nil) + puts "\n------------------------" + + puts "#{Colors.bold('Quest Number:')} #{number}" if number + + puts "#{Colors.bold('Quest:')} #{Colors.heading(quest.title)}" + puts "#{Colors.bold('Adventurer:')} #{quest.player.name}" + puts "#{Colors.bold('Description:')} #{quest.description}" + puts "#{Colors.bold('Difficulty:')} #{Colors.difficulty(quest.difficulty)}" + puts "#{Colors.bold('XP Reward:')} #{Colors.xp("#{quest.xp_reward} XP")}" + puts "#{Colors.bold('Status:')} #{Colors.status(quest.completed?)}" + puts "------------------------" + end + + def completion(quest, player, previous_level) + puts + puts Colors.bright_green("========================") + puts Colors.bold(Colors.bright_green(" QUEST COMPLETE!")) + puts Colors.bright_green("========================") + puts Colors.success(quest.title) + puts "#{player.name} earned #{Colors.xp("+#{quest.xp_reward} XP")}." + + level_up(player, previous_level) + + puts "#{Colors.bold('Current Level:')} #{Colors.xp(player.level)}" + puts "#{Colors.bold('Total XP:')} #{Colors.xp(player.current_xp)}" + end + + def abandonment(quest) + puts "\n#{Colors.warning('Quest abandoned successfully.')}" + puts "#{quest.title} has been removed." + puts Colors.yellow("No XP was awarded.") + end + + def quest_log(players) + if players.empty? + puts "\n#{Colors.warning('No adventurers have been created yet.')}" + return + end + + puts + puts "========================" + puts Colors.heading(" QUEST LOG") + puts "========================" + + players.each { |player| player_quest_log(player) } + end + + def errors(record) + puts "\n#{Colors.error('Unable to save quest:')}" + + record.errors.full_messages.each do |message| + puts Colors.red("- #{message}") + end + end + + private + + def player_quest_log(player) + quests = player.quests + completed_quests, active_quests = quests.partition(&:completed?) + + puts "\n#{Colors.heading(player.name)}" + puts "#{Colors.bold('Level:')} #{Colors.xp(player.level)} | " \ + "#{Colors.bold('XP:')} #{Colors.xp(player.current_xp)}" + puts "#{Colors.yellow('Active:')} #{active_quests.count}" + puts "#{Colors.green('Completed:')} #{completed_quests.count}" + + display_log_section("ACTIVE QUESTS", active_quests) + display_log_section("COMPLETED QUESTS", completed_quests) + + puts "========================" + end + + def display_log_section(title, quests) + puts "\n#{Colors.heading(title)}" + + if quests.empty? + puts Colors.warning("None") + return + end + + quests.each_with_index do |quest, index| + difficulty = Colors.difficulty(quest.difficulty) + reward = Colors.xp("#{quest.xp_reward} XP") + + puts "#{index + 1}. #{quest.title} | #{difficulty} | #{reward}" + end + end + + def level_up(player, previous_level) + return unless player.level > previous_level + + puts + puts Colors.bold(Colors.bright_magenta("★ LEVEL UP! ★")) + puts "#{player.name} reached " \ + "#{Colors.xp("Level #{player.level}")}!" + puts "Level #{previous_level} -> Level #{player.level}" + end +end diff --git a/cli/quest_menu.rb b/cli/quest_menu.rb new file mode 100644 index 0000000..ba3d850 --- /dev/null +++ b/cli/quest_menu.rb @@ -0,0 +1,252 @@ +require_relative "colors" +require_relative "quest_display" + +class QuestMenu + MENU_ACTIONS = { + "1" => :accept_quest, + "2" => :view_all_quests, + "3" => :view_player_quests, + "4" => :view_active_quests, + "5" => :view_completed_quests, + "6" => :update_quest, + "7" => :complete_quest, + "8" => :abandon_quest, + }.freeze + + def initialize + @display = QuestDisplay.new + end + + def run + loop do + @display.menu + choice = gets.chomp + + break if return_to_main_menu?(choice) + + perform_action(choice) + end + end + + private + + def return_to_main_menu?(choice) + return false unless choice == "9" + + puts "\n#{Colors.warning('Returning to the main menu...')}" + true + end + + def perform_action(choice) + action = MENU_ACTIONS[choice] + + if action + send(action) + else + puts "\n#{Colors.error('Invalid choice. Please select an option from 1 to 9.')}" + end + end + + def accept_quest + player = select_player + return unless player + + quest = player.quests.build(quest_attributes) + + if quest.save + puts "\n#{Colors.success('Quest accepted successfully!')}" + @display.quest_details(quest) + else + @display.errors(quest) + end + end + + def quest_attributes + print "\nQuest title: " + title = gets.chomp + + print "Description: " + description = gets.chomp + + print "Difficulty (Easy, Medium, or Hard): " + difficulty = gets.chomp.capitalize + + print "XP reward: " + xp_reward = gets.chomp + + { + title: title, + description: description, + difficulty: difficulty, + xp_reward: xp_reward, + } + end + + def view_all_quests + @display.quests(Quest.all) + end + + def view_player_quests + player = select_player + return unless player + + @display.quests(player.quests) + end + + def view_active_quests + @display.quests(Quest.where(completed: false)) + end + + def view_completed_quests + @display.quests(Quest.where(completed: true)) + end + + def update_quest + quest = select_quest + return unless quest + + if quest.update(updated_quest_attributes(quest)) + puts "\n#{Colors.success('Quest updated successfully!')}" + @display.quest_details(quest) + else + @display.errors(quest) + end + end + + def updated_quest_attributes(quest) + { + title: updated_value("Title", quest.title), + description: updated_value("Description", quest.description), + difficulty: updated_difficulty(quest), + xp_reward: updated_value("XP reward", quest.xp_reward), + } + end + + def updated_difficulty(quest) + value = updated_value("Difficulty", quest.difficulty) + + value == quest.difficulty ? value : value.capitalize + end + + def updated_value(label, current_value) + print "\n#{label} [#{current_value}]: " + input = gets.chomp + + input.empty? ? current_value : input + end + + def complete_quest + quest = select_active_quest + return unless quest + + @display.quest_details(quest) + + unless confirm_action?("Complete this quest?") + puts "\n#{Colors.warning('Quest completion canceled.')}" + return + end + + complete_quest_and_award_xp(quest) + end + + def complete_quest_and_award_xp(quest) + player = quest.player + previous_level = player.level + + Quest.transaction do + quest.update!(completed: true) + player.add_experience!(quest.xp_reward) + end + + @display.completion(quest, player, previous_level) + rescue ActiveRecord::RecordInvalid => e + @display.errors(e.record) + end + + def abandon_quest + quest = select_active_quest + return unless quest + + @display.quest_details(quest) + + unless confirm_action?("Abandon this quest?") + puts "\n#{Colors.warning('Quest abandonment canceled.')}" + return + end + + destroy_quest(quest) + end + + def destroy_quest(quest) + if quest.destroy + @display.abandonment(quest) + else + @display.errors(quest) + end + end + + def confirm_action?(message) + print "\n#{Colors.warning("#{message} (y/n): ")}" + gets.chomp.downcase == "y" + end + + def select_player + players = Player.all.to_a + + if players.empty? + puts "\n#{Colors.warning('No adventurers have been created yet.')}" + return + end + + puts "\n#{Colors.heading('Select an adventurer:')}" + + players.each_with_index do |player, index| + puts "#{Colors.cyan(index + 1)}. #{player.name}" + end + + print "\nEnter adventurer number: " + selected_player = record_at_index(players, gets.chomp) + + puts "\n#{Colors.error('Adventurer not found.')}" unless selected_player + + selected_player + end + + def select_quest + select_quest_from(Quest.all, "quest") + end + + def select_active_quest + select_quest_from( + Quest.where(completed: false), + "active quest" + ) + end + + def select_quest_from(quests, quest_type) + quests = quests.to_a + + if quests.empty? + puts "\n#{Colors.warning("No #{quest_type}s found.")}" + return + end + + @display.quest_choices(quests) + + print "\nEnter #{quest_type} number: " + selected_quest = record_at_index(quests, gets.chomp) + + puts "\n#{Colors.error("#{quest_type.capitalize} not found.")}" unless selected_quest + + selected_quest + end + + def record_at_index(records, input) + index = input.to_i - 1 + + return if index.negative? + return if index >= records.length + + records[index] + end +end diff --git a/config/environment.rb b/config/environment.rb index f7f8fa8..81bf943 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -9,8 +9,17 @@ # Set up database connection directly via Active Record require "yaml" + db_config = YAML.load_file(File.join(__dir__, "database.yml")) -ActiveRecord::Base.establish_connection(db_config[ENV.fetch("RACK_ENV", "development")]) + +ActiveRecord::Base.establish_connection( + db_config[ENV.fetch("RACK_ENV", "development")] +) # Require in all model files require_all "app/models" + +# Require reusable CLI classes +require_relative "../cli/player_menu" +require_relative "../cli/quest_menu" +require_relative "../cli/quest_cli" diff --git a/db/migrate/20260727192309_create_players.rb b/db/migrate/20260727192309_create_players.rb new file mode 100644 index 0000000..c0c506e --- /dev/null +++ b/db/migrate/20260727192309_create_players.rb @@ -0,0 +1,11 @@ +class CreatePlayers < ActiveRecord::Migration[7.2] + def change + create_table :players do |t| + t.string :name, null: false + t.integer :level, null: false, default: 1 + t.integer :current_xp, null: false, default: 0 + + t.timestamps + end + end +end diff --git a/db/migrate/20260728140853_create_quests.rb b/db/migrate/20260728140853_create_quests.rb new file mode 100644 index 0000000..8d7b04a --- /dev/null +++ b/db/migrate/20260728140853_create_quests.rb @@ -0,0 +1,14 @@ +class CreateQuests < ActiveRecord::Migration[7.2] + def change + create_table :quests do |t| + t.string :title, null: false + t.text :description + t.string :difficulty, null: false + t.integer :xp_reward, null: false + t.boolean :completed, null: false, default: false + t.references :player, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..e37bd52 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,35 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 2026_07_28_140853) do + create_table "players", force: :cascade do |t| + t.string "name", null: false + t.integer "level", default: 1, null: false + t.integer "current_xp", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "quests", force: :cascade do |t| + t.string "title", null: false + t.text "description" + t.string "difficulty", null: false + t.integer "xp_reward", null: false + t.boolean "completed", default: false, null: false + t.integer "player_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["player_id"], name: "index_quests_on_player_id" + end + + add_foreign_key "quests", "players" +end diff --git a/db/seeds.rb b/db/seeds.rb index 437ef89..1fb53ba 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,7 +1,51 @@ # frozen_string_literal: true -puts "🌱 Seeding spices..." +puts "🌱 Seeding QuestCLI..." -# Seed your database here +adventurer = Player.find_or_create_by!(name: "Neha") do |player| + player.level = 1 + player.current_xp = 0 +end +quests = [ + { + title: "Gather Healing Herbs", + description: "Collect medicinal herbs from the edge of the Whispering Woods.", + difficulty: "Easy", + xp_reward: 50, + }, + { + title: "Recover the Lost Satchel", + description: "Find the merchant's missing satchel along the old forest road.", + difficulty: "Easy", + xp_reward: 75, + }, + { + title: "Defeat the Cave Goblin", + description: "Enter the northern cave and defeat the goblin troubling nearby travelers.", + difficulty: "Medium", + xp_reward: 150, + }, + { + title: "Recover the Moonlit Amulet", + description: "Search the ruined temple and recover the ancient Moonlit Amulet.", + difficulty: "Hard", + xp_reward: 300, + }, +] + +quests.each do |attributes| + Quest.find_or_create_by!( + player: adventurer, + title: attributes[:title] + ) do |quest| + quest.description = attributes[:description] + quest.difficulty = attributes[:difficulty] + quest.xp_reward = attributes[:xp_reward] + quest.completed = false + end +end + +puts "✅ Seeded #{Player.count} adventurer(s)." +puts "✅ Seeded #{Quest.count} quest(s)." puts "✅ Done seeding!" diff --git a/user-stories.md b/user-stories.md new file mode 100644 index 0000000..dffb8f5 --- /dev/null +++ b/user-stories.md @@ -0,0 +1,576 @@ +# QuestCLI + +## Overview + +QuestCLI is a Ruby command-line application designed for users who want to manage an RPG-inspired adventurer and quest log. + +Users can create an adventurer, accept quests, update quest information, complete quests, earn experience points, level up, and abandon quests they no longer wish to pursue. + +The goal is to create a simple but engaging CLI experience that demonstrates CRUD operations, Active Record associations, database management, validations, and object-oriented Ruby design. + +--- + +# User Stories + +## Feature 1 - Create and Manage Adventurers + +**User Story:** As a user, I want to create and manage an adventurer so that I have a character who can accept quests and earn experience. + +### Details + +- Create a new player +- Enter a player name +- Assign a starting level of 1 +- Assign starting experience of 0 +- Save the player to the database +- View all saved players +- View an individual player's information +- Update a player's name +- Delete a player +- Display validation errors when player information is invalid +- Confirm before deleting a player + +--- + +## Feature 2 - Accept and View Quests + +**User Story:** As a user, I want my adventurer to accept quests so that they can begin working toward objectives and earning experience. + +### Details + +- Select the player receiving the quest +- Enter a quest title +- Enter a quest description +- Select a quest difficulty +- Assign an experience-point reward +- Save the quest to the database +- Automatically mark newly accepted quests as active +- Display a "Quest Accepted" confirmation message +- Display the newly accepted quest +- View all quests +- View quests belonging to a specific player +- View active quests +- View completed quests +- Display the player associated with each quest +- Display validation errors when quest information is invalid + +--- + +## Feature 3 - Update Quest Information + +**User Story:** As a user, I want to edit an existing quest so that I can correct mistakes or change the quest details. + +### Details + +- Select a quest to update +- Display the current quest title before requesting a new value +- Display the current description before requesting a new value +- Display the current difficulty before requesting a new value +- Display the current experience reward before requesting a new value +- Allow the user to keep an existing value by leaving the input blank +- Save updated quest information to the database +- Display confirmation after the quest is updated +- Display validation errors when updated information is invalid + +--- + +## Feature 4 - Complete Quests and Earn Experience + +**User Story:** As a user, I want my adventurer to complete accepted quests so that they earn experience and grow stronger. + +### Details + +- Select an active quest +- Confirm quest completion +- Mark the quest as completed +- Add the quest's experience reward to the associated player +- Save the updated quest +- Save the player's updated experience +- Display a "Quest Complete" confirmation message +- Display the amount of experience earned +- Display the player's updated level and experience +- Prevent a completed quest from being completed twice +- Prevent a completed quest from awarding experience more than once + +--- + +## Feature 5 - Player Level Progression + +**User Story:** As a user, I want my adventurer to level up after earning enough experience so that completing quests feels rewarding. + +### Details + +- Calculate a player's level using accumulated experience +- Check for a level increase after completing a quest +- Update the player's level when the required experience is reached +- Display a level-up message +- Display the player's current level +- Display the player's current experience +- Keep the level calculation inside the Player model + +--- + +## Feature 6 - Abandon Quests + +**User Story:** As a user, I want to abandon quests so that I can remove objectives I no longer want to pursue. + +### Details + +- Select a quest to abandon +- Display the selected quest before deletion +- Ask the user to confirm the deletion +- Delete the quest from the database +- Return to the quest menu after deletion +- Display confirmation after the quest is abandoned +- Handle an invalid quest selection without crashing the application + +--- + +## Feature 7 - CLI Navigation and User Feedback + +**User Story:** As a user, I want a clear menu-driven interface so that I can navigate the application and understand the result of each action. + +### Details + +- Display a main menu +- Display numbered menu choices +- Keep the application running inside a loop +- Allow the user to return to the previous menu +- Allow the user to exit the application +- Handle invalid menu choices +- Display clear success messages +- Display clear error messages +- Format player and quest information for readability +- Separate CLI responsibilities into at least two Ruby classes +- Use reusable helper methods to avoid repeated code + +--- + +## Feature 8 - Color-Coded CLI Feedback + +**User Story:** As a player, I want important information in the CLI to use consistent colors so that I can quickly understand quest difficulty, status, rewards, errors, and successful actions. + +### Details + +- Menu headings and decorative separators remain white. +- Successful actions and completed quests display in green. +- Errors and invalid selections display in red. +- Prompts, warnings, confirmations, and active quests display in yellow. +- XP rewards and level-up information display in magenta. +- Easy difficulty displays in green. +- Medium difficulty displays in yellow. +- Hard difficulty displays in red. +- Color formatting is managed through a reusable `Colors` module. +- Existing player and quest functionality continues to work. + +--- + +## Feature 9 - Index-Based Record Selection + +**User Story:** As a user, I want players and quests displayed as consecutive numbered choices so that record selection is clear even when database records have been deleted. + +### Details + +- Display players using consecutive list numbers beginning at 1. +- Display quests using consecutive list numbers beginning at 1. +- Allow the user to select a player by its displayed list number. +- Allow the user to select a quest by its displayed list number. +- Keep database IDs hidden from normal CLI selection. +- Continue using Active Record records internally. +- Handle selections outside the displayed range without crashing. +- Apply index-based selection consistently throughout the player and quest menus. + +--- + +## Feature 10 - Seeded Starter Adventure + +**User Story:** As a reviewer, I want the database to contain starter content so that I can immediately explore the application without manually creating every record. + +### Details + +- Seed at least one sample adventurer. +- Seed multiple quests belonging to the sample adventurer. +- Include easy, medium, and hard quests. +- Give every seeded quest a title, description, difficulty, XP reward, and active status. +- Ensure seeded records satisfy all model validations. +- Allow the reviewer to view, update, complete, and abandon seeded quests. +- Prevent repeated seeding from creating uncontrolled duplicate records. + +--- + +# Quest Lifecycle + +```text +Quest Accepted + │ + ▼ +Active Quest + │ + ▼ +Quest Updated (optional) + │ + ▼ +Quest Completed + │ + ▼ +Experience Awarded + │ + ▼ +Player Levels Up (if enough XP) +``` + +A quest may also be abandoned before it is completed. + +--- + +# MVP Features + +The initial project scope will include: + +- Create players +- View players +- Update players +- Delete players +- Accept quests +- View all quests +- View quests belonging to a player +- Update quests +- Complete quests +- Abandon quests +- Award experience for completed quests +- Prevent completed quests from awarding experience twice +- Level up players +- Display validation errors +- Menu-driven CLI navigation + +--- + +# CLI Menus + +## Main Menu + +```txt +1. Manage Players +2. Manage Quests +3. View Quest Log +4. Exit +``` + +## Player Menu + +```txt +1. Create Player +2. View All Players +3. View Player Details +4. Update Player +5. Delete Player +6. Return to Main Menu +``` + +## Quest Menu + +```txt +1. Accept Quest +2. View All Quests +3. View Active Quests +4. View Completed Quests +5. Update Quest +6. Complete Quest +7. Abandon Quest +8. Return to Main Menu +``` + +--- + +# Planned Classes + +- Player +- Quest +- QuestCLI +- PlayerMenu +- QuestMenu + +The exact CLI class structure may be adjusted during development, but the application will contain at least two Ruby classes responsible for CLI behavior. + +--- + +# Database Structure + +## Players + +```txt +players +------- +id +name +level +current_xp +created_at +updated_at +``` + +## Quests + +```txt +quests +------ +id +title +description +difficulty +xp_reward +completed +player_id +created_at +updated_at +``` + +--- + +# Model Relationships + +A Player has many Quests. + +A Quest belongs to one Player. + +```text +Player + | + | has many + | + ▼ +Quest +``` + +```ruby +class Player < ActiveRecord::Base + has_many :quests, dependent: :destroy +end + +class Quest < ActiveRecord::Base + belongs_to :player +end +``` + +--- + +# Validations + +## Player Validations + +- Name must be present +- Name must be unique +- Level must be greater than 0 +- Current experience must be 0 or greater + +## Quest Validations + +- Title must be present +- Difficulty must be present +- Experience reward must be greater than 0 +- Completed must be either true or false +- Player must exist + +--- + +# Technical Challenges + +## Managing Active Record Associations + +Creating quests that correctly belong to a player and displaying the associated records. + +## Updating Existing Records + +Showing the current database value before asking the user to enter an updated value. + +## Experience and Level Progression + +Awarding experience when a quest is completed while preventing the same quest from awarding experience multiple times. + +## User Input Validation + +Handling invalid menu choices, missing records, blank values, and failed Active Record validations without crashing the application. + +## CLI Organization + +Separating menu navigation, user input, and database interactions into organized and reusable Ruby methods and classes. + +--- + +# Meeting Project Requirements + +## Active Record + +All database interactions will use Active Record. + +## Models + +The application will contain at least two Active Record models: + +- Player +- Quest + +## One-to-Many Relationship + +- Player has many Quests +- Quest belongs to Player + +## CRUD Operations + +The application will allow users to create, read, update, and delete player and quest records. + +## Validations + +At least one model will contain Active Record validations. + +## CLI Classes + +The application will contain at least two Ruby classes responsible for menu navigation and CLI behavior. + +## Menu Loop + +The application will use a loop-based menu that continues running until the user chooses to exit. + +## Update Prompts + +Update prompts will display the current value before asking the user to enter a replacement. + +--- + +# Stretch Goals + +These features are not required for the MVP: + +- Character classes +- Gold rewards +- Items +- Adventurer inventory +- Quest prerequisites +- Store +- Quest steps and interactions +- Full seed data +- Final balancing and CLI polish + + +# Stretch Goal User Stories + +These user stories describe optional features that expand QuestCLI into a more complete playable RPG experience. + +## Feature 11 - Character Classes + +**User Story:** As a player, I want my adventurer to specialize into a character class so that the adventurer has a distinct identity and progression path. + +### Details + +- An eligible adventurer can choose from available character classes. +- The selected class is saved to the adventurer. +- An adventurer cannot select an invalid class. +- An adventurer cannot repeatedly change classes unless the game explicitly allows it. +- The adventurer's class appears in their details. +- Existing adventurers remain valid before selecting a class. + +## Feature 12 - Gold Rewards + +**User Story:** As a player, I want quests to reward gold so that I can purchase useful items. + +### Details + +- Players have a gold balance. +- Quests may define a gold reward. +- Completing a quest adds its gold reward to the player. +- Gold is awarded only once per quest. +- The player's current gold appears in their details. + +## Feature 13 - Quest Item Rewards + +**User Story:** As a player, I want quests to reward items so that completing adventures can unlock tools and valuable objects. + +### Details + +- Quests may reward one or more items. +- Quest rewards are added to the player's inventory. +- Rewards are granted only after successful quest completion. +- The CLI displays the items earned. +- The same reward is not granted more than once. + +## Feature 14 - Adventurer Inventory + +**User Story:** As a player, I want my adventurer to have an inventory so that I can collect, view, and use items. + +### Details + +- A player can own multiple items. +- Inventory tracks the quantity of each item. +- The player can view their inventory. +- Purchased and rewarded items are added to inventory. +- Empty inventories display a clear message. + +## Feature 15 - Quest Prerequisites + +**User Story:** As a player, I want quests to have prerequisites so that progression feels meaningful and advanced quests must be earned. + +### Details + +- A quest may require a minimum level. +- A quest may require a character class. +- A quest may require one or more inventory items. +- A quest may require another quest to be completed. +- The player cannot begin an unavailable quest. +- The CLI explains every unmet prerequisite. + +## Feature 16 - Item Store + +**User Story:** As a player, I want to purchase items from a store so that I can prepare for future quests. + +### Details + +- The store displays available items. +- Each item displays a name, description, and gold price. +- Players can purchase items they can afford. +- Purchased items are added to inventory. +- The cost is deducted from the player's gold. +- Players cannot purchase items they cannot afford. +- Invalid purchases display a clear error. + +## Feature 17 - Interactive Quest Steps + +**User Story:** As a player, I want quests to contain interactive steps so that completing a quest feels like playing an adventure instead of selecting a single menu option. + +### Details + +- A quest contains one or more ordered steps. +- The number and complexity of steps can vary by difficulty. +- Steps may include riddles, combat, choices, obstacles, dialogue, and item use. +- Required steps must be completed in order. +- Failed or invalid input does not automatically complete the quest. +- The quest completes only after all required steps are finished. +- Rewards are granted after the final step. + +## Feature 18 - Difficulty-Based Quest Depth + +**User Story:** As a player, I want harder quests to contain more challenging and longer interactions so that difficulty affects gameplay rather than only rewards. + +### Details + +- Easy quests contain a small number of simple steps. +- Medium quests contain multiple interactions. +- Hard quests contain longer sequences and more demanding prerequisites. +- Difficulty affects XP, gold, and possible item rewards. +- The CLI clearly displays quest difficulty. + +## Feature 19 - Seeded Adventure + +**User Story:** As a new player, I want the game to include a complete seeded adventure so that I can create an adventurer and begin playing immediately. + +### Details + +- The seed file creates reusable items. +- The seed file creates store inventory. +- The seed file creates quests of every difficulty. +- Seeded quests include XP and gold rewards. +- Some quests include item rewards. +- Some quests include prerequisites. +- Seeded quests contain interactive steps. +- Quests form a logical progression. +- Running the seed task does not create uncontrolled duplicate data. +- A new user can play without manually creating every quest.