From 6e914467ed1375591f2fb9c2fa1a7925e9bb7823 Mon Sep 17 00:00:00 2001 From: nathan Date: Wed, 7 Jan 2026 23:11:46 -0700 Subject: [PATCH] Basic working with ships partly implimented --- .gitignore | 1 + .rspec | 1 + Rakefile | 34 +++ bin/server | 2 +- config/environment.rb | 7 - config/logging.rb | 3 - config/settings.rb | 5 - dev_notes.md | 3 +- game_data/game_conf.yaml | 14 ++ lib/app.rb | 60 +++++ lib/commands/player_commands.rb | 0 lib/commands/ship_commands.rb | 0 lib/domain/planet.rb | 0 lib/domain/player.rb | 11 - lib/domain/ship.rb | 0 lib/events/build_event.rb | 0 lib/events/combat_event.rb | 0 lib/events/event.rb | 0 lib/game/buildings.yaml | 163 +++++++++++++ lib/game/game.rb | 400 ++++++++++++++++++++++++++++++++ lib/game/modules.yaml | 40 ++++ lib/game/planet.rb | 75 ++++++ lib/game/player.rb | 156 +++++++++++++ lib/game/ship.rb | 146 ++++++++++++ lib/game/ships.yaml | 235 +++++++++++++++++++ lib/game_server.rb | 8 - lib/logging/context.rb | 18 -- lib/logging/formatter.rb | 11 - lib/logging/logger.rb | 19 -- lib/protocol/errors.rb | 0 lib/protocol/message.rb | 0 lib/protocol/validator.rb | 0 lib/queries/player_queries.rb | 0 lib/queries/ship_queries.rb | 0 lib/server/connection.rb | 27 --- lib/server/message_router.rb | 61 ----- lib/server/websocket_server.rb | 33 --- lib/simulation/clock.rb | 0 lib/simulation/combat.rb | 0 lib/simulation/economy.rb | 0 lib/simulation/movement.rb | 0 lib/simulation/tick_loop.rb | 0 lib/simulation/world.rb | 15 -- lib/utils/math.rb | 0 spec/game/game_spec.rb | 172 ++++++++++++++ spec/game/planet_spec.rb | 79 +++++++ spec/game/player_spec.rb | 295 +++++++++++++++++++++++ spec/game/ship_spec.rb | 106 +++++++++ spec/spec_helper.rb | 99 ++++++++ 49 files changed, 2079 insertions(+), 220 deletions(-) create mode 100644 .rspec create mode 100644 Rakefile delete mode 100644 config/environment.rb delete mode 100644 config/logging.rb delete mode 100644 config/settings.rb create mode 100644 game_data/game_conf.yaml create mode 100644 lib/app.rb delete mode 100644 lib/commands/player_commands.rb delete mode 100644 lib/commands/ship_commands.rb delete mode 100644 lib/domain/planet.rb delete mode 100644 lib/domain/player.rb delete mode 100644 lib/domain/ship.rb delete mode 100644 lib/events/build_event.rb delete mode 100644 lib/events/combat_event.rb delete mode 100644 lib/events/event.rb create mode 100644 lib/game/buildings.yaml create mode 100644 lib/game/game.rb create mode 100644 lib/game/modules.yaml create mode 100644 lib/game/planet.rb create mode 100644 lib/game/player.rb create mode 100644 lib/game/ship.rb create mode 100644 lib/game/ships.yaml delete mode 100644 lib/game_server.rb delete mode 100644 lib/logging/context.rb delete mode 100644 lib/logging/formatter.rb delete mode 100644 lib/logging/logger.rb delete mode 100644 lib/protocol/errors.rb delete mode 100644 lib/protocol/message.rb delete mode 100644 lib/protocol/validator.rb delete mode 100644 lib/queries/player_queries.rb delete mode 100644 lib/queries/ship_queries.rb delete mode 100644 lib/server/connection.rb delete mode 100644 lib/server/message_router.rb delete mode 100644 lib/server/websocket_server.rb delete mode 100644 lib/simulation/clock.rb delete mode 100644 lib/simulation/combat.rb delete mode 100644 lib/simulation/economy.rb delete mode 100644 lib/simulation/movement.rb delete mode 100644 lib/simulation/tick_loop.rb delete mode 100644 lib/simulation/world.rb delete mode 100644 lib/utils/math.rb create mode 100644 spec/game/game_spec.rb create mode 100644 spec/game/planet_spec.rb create mode 100644 spec/game/player_spec.rb create mode 100644 spec/game/ship_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore index d752c31..b58f348 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ vendor/* Gemfile.lock .bundle/* +game_data/* diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5d2f13a --- /dev/null +++ b/Rakefile @@ -0,0 +1,34 @@ +# Nathan Hinton 2 Oct 2025 +# Rake file + + +desc 'Run RSpec tests' +task :test do + begin + require 'rspec/core/rake_task' + RSpec::Core::RakeTask.new(:spec) + Rake::Task['spec'].invoke + rescue LoadError + puts 'Failed to load RSpec to run tests... Try installing rspec' + end +end + + +desc 'Run the server for clients to connect to' +task :serve do + sh 'ruby -Ilib lib/app.rb' +end + + +namespace :doc do + desc 'Create the YARD documentation for the project' + task :yard do + res = sh 'yard doc lib' + sh 'yard stats lib --list-undoc' + end + + desc 'Create code coverage' + task :coverage do + Rake::Task[:test].invoke + end +end diff --git a/bin/server b/bin/server index dc736e5..f631598 100755 --- a/bin/server +++ b/bin/server @@ -1,4 +1,4 @@ #!/usr/bin/env ruby -require_relative "../config/environment" +require_relative "../lib/app" GameServer::App.start diff --git a/config/environment.rb b/config/environment.rb deleted file mode 100644 index 9ffcdd0..0000000 --- a/config/environment.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "bundler/setup" -Bundler.require - -require_relative "settings" -require_relative "logging" - -Dir[File.join(__dir__, "../lib/**/*.rb")].sort.each { |f| require f } diff --git a/config/logging.rb b/config/logging.rb deleted file mode 100644 index b183ace..0000000 --- a/config/logging.rb +++ /dev/null @@ -1,3 +0,0 @@ -require_relative "../lib/logging/logger" - -GameServer::Logging.setup diff --git a/config/settings.rb b/config/settings.rb deleted file mode 100644 index 56d1382..0000000 --- a/config/settings.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Settings - PORT = 8080 - GAME_SPEED = 1.0 - SIMULATION_STEP = 0.05 -end diff --git a/dev_notes.md b/dev_notes.md index a76b4d9..ff652cc 100644 --- a/dev_notes.md +++ b/dev_notes.md @@ -8,7 +8,7 @@ Here is my newly defined API: # API -Transport: WebSocket +Transport: TCP Sockets Encoding: JSON Direction: Bidirectional @@ -20,6 +20,7 @@ Every message has: "domain": "...", "request_id": integer "payload": {} + "player_id" } ``` diff --git a/game_data/game_conf.yaml b/game_data/game_conf.yaml new file mode 100644 index 0000000..86e19b0 --- /dev/null +++ b/game_data/game_conf.yaml @@ -0,0 +1,14 @@ +--- +name: Test world +width: 10 +height: 10 +depth: 1 +players: [] +planets: [] +ships: [] +time: 0.0 +player_defaults: + credits: 2000 + metal: 2000 + crystals: 2000 + fuel: 2000 diff --git a/lib/app.rb b/lib/app.rb new file mode 100644 index 0000000..68a4864 --- /dev/null +++ b/lib/app.rb @@ -0,0 +1,60 @@ +# Nathan Hinton 22 Dec 2025 +# Here is where I will have the server start + +require 'socket' +require 'json' +require 'yaml' + +require 'game/game' + +server = TCPServer.new(2000) +puts "Multithreaded server listening on port 2000..." + +conf_data = {} +configuration_file = 'game_data/game_conf.yaml' +File.open(configuration_file, 'r') do |fi| + conf_data = YAML.safe_load(fi.read()) +end + +puts "loaded #{conf_data} for game" +game = Game::Game.new(conf_data) + +game_thread = Thread.new() do + game.run() +end + + +loop do + # Accept the connection and pass the client object to a new thread + Thread.start(server.accept) do |client| + puts "New thread started for a client connection." + + # Now we start to connect commands to the server + + #client.puts ({'type' => 'update', 'domain' => 'game', 'request_id' => nil, 'payload' => {'game_name' => conf_data['name']}}).to_json + + # Read data from the client + while line = client.gets + # Ensure that it is JSON + begin + encoded_command = JSON.parse(line.chomp) + response = '' + if encoded_command['type'] == 'hello' + # Here we need to get the client information. + response = game.player_connect(client, encoded_command).to_json + else + response = game.parse_command(encoded_command).to_json + end + puts "Received: #{line.chomp}" + puts "Sending: #{response}" + client.puts "#{response}" + rescue JSON::ParserError + puts "Error decoding JSON: '#{line.chomp}'" + client.puts ({'type' => 'error', 'domain' => 'error', 'payload' => 'Server unable to decode JSON'}).to_json() + end + end + + client.close + puts "Thread completed and client disconnected." + end +end diff --git a/lib/commands/player_commands.rb b/lib/commands/player_commands.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/commands/ship_commands.rb b/lib/commands/ship_commands.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/domain/planet.rb b/lib/domain/planet.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/domain/player.rb b/lib/domain/player.rb deleted file mode 100644 index 1595e28..0000000 --- a/lib/domain/player.rb +++ /dev/null @@ -1,11 +0,0 @@ -module GameServer - module Domain - class Player - attr_reader :money - - def initialize - @money = 1000 - end - end - end -end diff --git a/lib/domain/ship.rb b/lib/domain/ship.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/events/build_event.rb b/lib/events/build_event.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/events/combat_event.rb b/lib/events/combat_event.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/events/event.rb b/lib/events/event.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/game/buildings.yaml b/lib/game/buildings.yaml new file mode 100644 index 0000000..1909a03 --- /dev/null +++ b/lib/game/buildings.yaml @@ -0,0 +1,163 @@ +# Nathan Hinton 2 Jan 2025 Buildings for the game +# + +# Production buildings: +Shipyard Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Shipyard Level 2: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Space Construction Station Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Space Construction Station Level 2: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Research Factory Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Research Factory Level 2: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Shipyard Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Shipyard Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Shipyard Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] + +Shipyard Level 1: + classification: production + faction: good + production: # This is what is required to produce the building: + metal: + crystals: + fuel: + credits: + time: + upkeep: + metal: + crystals: + fuel: + credits: + base_stats: [] diff --git a/lib/game/game.rb b/lib/game/game.rb new file mode 100644 index 0000000..149cdee --- /dev/null +++ b/lib/game/game.rb @@ -0,0 +1,400 @@ +# frozen_string_literal: true + +# Nathan Hinton, 22 Dec 2025 +# Game server for the space game + +require 'game/player' +require 'game/planet' +require 'thread' +require 'securerandom' + +# Main file for the Game module +module Game + + # Controls how frequently autosaves are created (seconds) + SAVE_INTERVAL = 15 + + # Controls how frequently updates are sent to the client about the player (seconds) + PLAYER_UPDATE_INTERVAL = 2 + + # Controls how fast the game scales with player count. Bigger means more + # players required to change the speed. + GAME_SPEED_FACTOR = 1 + + ## + # The central server object that keeps track of players, planets, and + # game-time. + # + # @api public + class Game + # The name of the game + attr_reader :name + + # Returns the width of the game space + attr_reader :width + + # Returns the height of the game space + attr_reader :height + + # Returns the depth of the game space + attr_reader :depth + + # Collection of players in the game + attr_reader :players + + # Create the game + ## + # Initialize the game from a configuration hash. The hash is expected + # to be the result of parsing the YAML file `game_data/game_conf.yaml`. + # + # @param conf_data [Hash] The parsed configuration + # @raise [ArgumentError] if the configuration is missing a required key + # + def initialize(conf_data) + # Validate that the correct keys exist + ['name', 'width', 'height', 'depth', 'players', 'planets', 'ships', 'time', 'player_defaults'].each do |key| + if conf_data[key].nil? + raise ArgumentError, "Missing toplevel key '#{key}' in configuration" + end + end + @conf_data = conf_data + @name = conf_data['name'] + @width = conf_data['width'] + @height = conf_data['height'] + @depth = conf_data['depth'] + @to_load_players = conf_data['players'] + @to_load_planets = conf_data['planets'] + @game_time = conf_data['time'] + @player_defaults = @conf_data['player_defaults'] + @connected_players = [] # When we start up we do not have any players connected. + + # Load the players: + @players = [] + @to_load_players.each do |player_id| + @players.append(Player.load_from_save(player_id)) + end + + # If we are an integer then we need to create new planets. + @planets = [] + if @to_load_planets.is_a? Integer + puts "Appears to be the first time running. Creating #{@to_load_planets} planets" + @to_load_planets.times do + @planets.append(Planet.new(@width, @height, @depth)) + end + else + @to_load_planets.length.times do |idx| + planet = Planet.load_from_save(@to_load_planets[idx]) + @planets.append(planet) + end + end + + # Load ships + @ships = [] + conf_data['ships'].each do + require 'byebug'; debugger + end + end + + ## + # Register a client connection. This method sccepts a TCP docket and a hash + # that must comtain a `player_id`. If the player already exists in the game + # it is reconnected otherwise a new `Player` is created and added to the + # game if possible (There must be at least one free planet) + # + # @param client [TCPSocket] The client socket + # @param command [Hash] JSON parsed command from the client. + # @option command [String] :player_id Required, The player ID + # + # @return [Hash] status hash that will be converted to JSON before being + # sent back to the client + # + def player_connect(client, command) + player_id = command.dig('payload', 'player_id') + faction = command.dig('payload', 'faction') + raise ArgumentError, 'player_id missing in command' unless player_id + raise ArgumentError, 'faction is missing in command' unless faction + + player = player_registered?(player_id) + + if player + # Re-connect an existing but disconnected player + if player.connected? + raise GameError, 'Player already connected!' + end + player.connect(client) + else + # Create a brand new player and claim a planet for them + player = create_player(player_id, faction) + return player if player.is_a?(Hash) && player['type'] == 'error' # error returned + + player.connect(client) + @players << player + end + + { 'type' => 'update', 'payload' => 'success' } + end + + + ## + # Create a new player from scratch. The new player is assigned a free + # planet and receives the default starting resources. + # + # @param player_id [String] the unique identifier that will be stored + # on the new player; if `nil` a random hex string will be generated + # @param faction [String] The faction that the player belongs too. + # + # @return [Player, Hash] The new player object on success or an error + # hash if no free planet is available. + # + def create_player(player_id, faction) + free_planets = @planets.select { |p| p.owner.nil? } + + if free_planets.empty? + return { + 'type' => 'error', + 'payload' => 'Unable to allocate a planet for a new player' + } + end + + home = free_planets.sample + player = Player.new(player_id || SecureRandom.hex, faction) + + player.create_new( + @player_defaults['credits'], + @player_defaults['metal'], + @player_defaults['crystals'], + @player_defaults['fuel'], + ) + + player.claim_planet(home) + player.make_home_planet(home) + player + end + + ## + # The entry point for all messages that arrive from a client. The + # payload must be a hash decoded from JSON and contain a `type` field. + # + # @param command [Hash] The parsed client command + # + # @return [String] JSON-encoded response + # + def parse_command(command) + return error_response('Player not registered!') unless command['player_id'] + + case command['type'] + when 'dbg' + # Debugger - do nothing in production + { 'type' => 'ok', 'payload' => 'debugging' }.to_json + when 'hello' + error_response('Hello command should already have been handled.') + when 'query' + parse_query(command) + when 'command' + error_response('Command handling not implemented') + else + error_response('Unknown type') + end + end + + ## + # Dispatch a `query` command to the appropriate helper. Only a small + # subset of query types is required for the test-suite; all others + # return a generic error. + # + # @param query [Hash] The query to be parsed + # + # @return [String] JSON-encoded response + # + def parse_query(query) + return error_response('Missing query payload') unless query['payload'] + player = player_registered?(query['player_id']) + return error_response('Player not found') unless player + + case query['domain'] + when 'player' + case query['payload'] + when 'update' + return player.update(query['payload']['request_id']) + else + error_response('Unknown payload for player domain') + end + else + error_response('Unknown domain') + end + end + + ## + # Return a status hash that contains a friendly error message. The + # helper exists only to keep the body of all error branches short. + # + # @param msg [String] The error message + # @return [String] JSON-encoded error response + # + def error_response(msg) + { 'type' => 'error', 'payload' => msg }.to_json + end + alias error_response error_response + + ## + # Return a JSON object that tells a client about the current + # server-time. Useful for debugging or “ping” operations. + # + # @return [String] JSON-encoded hash + # + def info(request_id) + { + 'type' => 'update', + 'domain' => 'game', + 'request_id' => request_id, + 'payload' => { + 'name' => @name, + 'width' => @width, + 'height' => @height, + 'depth' => @depth, + 'time' => @game_time + } + }.to_json + end + + # Actually start running the game + def run() + # Main game loop + last_time = Time.now # The delta time will be very small on the first one. + next_save = @game_time + SAVE_INTERVAL + next_client_update = @game_time + PLAYER_UPDATE_INTERVAL + while true + # This is the **ONLY** place where Time.now should be used + delta_time = (Time.now - last_time) + last_time = Time.now + + # Here is where we do our time scaling. Count the number of players in the game then use that mult + player_count = @players.map {|player| player.connected?}.count true + @game_speed = (player_count / GAME_SPEED_FACTOR) + delta_time * (player_count / GAME_SPEED_FACTOR) + @game_time += delta_time + + @planets.each do |planet| + planet.update(delta_time) + end + + @ships.each do |ship| + ship.update(delta_time) + end + + #@players.each do |player| + # player.update(delta_time) + #end + + # Perform game saves (Or not for now...) + if (@game_time) > next_save + puts 'Trying to save...' + next_save = @game_time + SAVE_INTERVAL + save_game_state + end + + # Send out regular updates to the clients (Every 2 seconds) + if @game_time > next_client_update + next_client_update = @game_time + PLAYER_UPDATE_INTERVAL + @players.each do |player| + if player.connected? + player.send_update + end + end + end + + if player_count == 0 + puts 'No players connected, pausing time' + while 0 == (@players.map {|player| player.connected?}.count true) + sleep(1) + end + end + #sleep(0.05) # About 20 TPS/FPS + sleep(1) + end + end + + ## + # Persist the current state of the game to the disk. The method is + # fully unit-tested via a double that captures the YAML payload + # instead of actually writing a file. + # + # @return [void] + # + def save_game_state + config = { + 'name' => @name, + 'width' => @width, + 'height' => @height, + 'depth' => @depth, + 'players' => @players.map(&:player_id), + 'planets' => @planets.map(&:planet_id), + 'ships' => @ships.map(&:ship_id), + 'time' => @game_time, + 'player_defaults' => @player_defaults + } + + File.open('game_data/game_conf.yaml', 'w') { |f| f.write(YAML.dump(config)) } + + # Save the planets + @planets.each do |planet| + planet.save_to_file + end + + # Save the ships + @ships.each do |ship| + ship.save_to_file + end + + # Save the players + @players.each do |player| + player.save_to_file + end + end + + ## + # Determine whether a player with a given id is known to the game. + # + # @param player_id [String] the id that the caller is looking for + # + # @return [Player, false] the `Player` instance if found or `false` + # otherwise + # + def player_registered?(player_id) + @players.find { |p| p.player_id == player_id } || false + end + + # -------------------------------------------------------------------- + # PRIVATE HELPERS - kept simple for the test-suite + # -------------------------------------------------------------------- + + private + + def error_response(msg) + { 'type' => 'error', 'payload' => msg }.to_json + end + + def load_players(ids) + ids.map { |id| Player.load_from_id(id) } + end + + def load_planets(planets) + if planets.is_a?(Integer) # legacy config - integer means “create N” + Array.new(planets) { Planet.new } + else + planets.map { |id| Planet.load_from_id(id) } + end + end + + def load_ships(ids) + ids.map { |id| Ship.load_from_id(id) } # Ship is a placeholder + end + end + + ## + # Raised when an invalid operation is attempted (e.g. reconnecting a + # player that already has a socket). The tests catch this error + # to confirm that the server protects against accidental double + # connections. + class GameError < StandardError; end +end diff --git a/lib/game/modules.yaml b/lib/game/modules.yaml new file mode 100644 index 0000000..7a9ca91 --- /dev/null +++ b/lib/game/modules.yaml @@ -0,0 +1,40 @@ +# Nathan Hinton 31 Dec 2025 +# This holds a list of all modules in the game + +# Here is how to create a module: +# +# name: +# classification: +# auxiluary/attack/defense # Pick one +# faction: +# good/evil +# production: +# metal/crystals/fuel +# upkeep: +# metal/crystals/fuel +# add_stats: # These are additive to the base stat +# stat_name: value +# mult_stats: # These are multipulicative to the base stat (After adding the additive stats?) +# stat_name: value # Greater than one will increase while less than one will decrease +# required_tech: [] # The required tech to be researched *before* this one can be started +# required_buildings: [] # The buildings required to begin this research +# +# +# + +light shield: + classification: defense + faction: good + production: + metal: 1,000 + crystals: 1,000 + fuel: 0 + upkeep: + metal: 10 + crustals: 10 + fuel: 0 + add_stats: + shield: 1,000 + mult_stats: [] + required_tech: [] + required_buildings: [] diff --git a/lib/game/planet.rb b/lib/game/planet.rb new file mode 100644 index 0000000..161e8d3 --- /dev/null +++ b/lib/game/planet.rb @@ -0,0 +1,75 @@ +# Nathan Hinton 22 Dec 2025 +# Planet file + +require 'securerandom' + +# A planet object for the Game module +class Planet + attr_reader :owner, :planet_id + + # Creates a new planet. + # + # @param width [Integer] The width of the game + # @param height [Integer] The height of the game + # @param depth [Integer] The depth of the game + def initialize(width, height, depth) + @owner = nil + @buildings = [] + @position = [rand(width), rand(height), rand(depth)] + @planet_id = SecureRandom.hex + end + + # Returns if the planet can be claimed. Currently requires that there be no + # buildings on the planet. + def claimable? + if @owner.nil? + return true + elsif @buildings == [] + return true + end + return false + end + + # Function that allows a player to claim the planet and to become the owner. + def claim(player) + @owner = player + end + + # Function called to update the planet. + def update(delta_time) + end + + + # Function to return info to the client about a planet + def info_update + raise NotImplementedError, "Need to write tests for this function" + return { + 'id' => @planet_id, + 'position' => @position + } + end + + + ########################## + ##### UTIL FUNCTIONS ##### + ########################## + + + # Save a player to a file + def save_to_file + File.open("game_data/#{self.planet_id}_planet_.save", 'wb') do |fi| + # Load resources for the player: + fi.write(Marshal.dump(self)) + end + return self.planet_id + end + + # Load a player from a file + def self.load_from_save(fname) + File.open("game_data/#{fname}_planet_.save", 'rb') do |fi| + # Load resources for the player: + Marshal.load(fi.read) + end + end + +end diff --git a/lib/game/player.rb b/lib/game/player.rb new file mode 100644 index 0000000..90ce4d6 --- /dev/null +++ b/lib/game/player.rb @@ -0,0 +1,156 @@ +# Nathan Hinton 22 Dec 2025 +# The main player file + +require 'securerandom' +require 'yaml' + +# A player object for the class Game +class Player + attr_reader :player_resources, :player_id, :player_planets, :player_ships, :faction, :researched_tech, :buildings + + def initialize(player_id, faction) + @player_id = player_id + @player_planets = [] + @player_ships = [] + @home_planet = nil + + # Validate faction: + if !(['good', 'evil'].include? faction) + raise PlayerError, 'Faction must be `good` or `evil`' + end + @faction = faction + end + + # Create a new player. This is actually just setting it up. All the defaults + # are defined in the game_config.yaml file. + # + # @param credits [Integer] The number of starting credits + # @param metal [Integer] The amount of starting metal + # @param crystals [Integer] The number of starting crystals + # @param fuel [Integer] The amount of fuel to start with. + # @param faction [String] The faction that the player should start as. Should be `good` or `evil` + def create_new(credits, metal, crystals, fuel) + @player_resources = { + 'credits' => credits, + 'metal' => metal, + 'crystals' => crystals, + 'fuel' => fuel + } + end + + # Connect a player + # + # @param client [TCPSocket] The socket used to communicate with the player + def connect(client) + @socket = client + end + + # Returns if the player is connected or not + def connected? + if @socket.nil? + return false + elsif @socket.closed? + return false + end + return true + end + + # Allows a player to claim a planet. + # + # @param planet + def claim_planet(planet) + if planet.claimable? + @player_planets.append(planet) + planet.claim(self) + end + end + + # Makes a planet the player's home planet + # + # @param planet + def make_home_planet(planet) + if planet.owner == self + @home_planet = planet + else + return {'type' => 'error', + 'payload' => 'Planet must be owned by the player'} + end + end + + # Create an update to send out + def update(request_id) + planet_data = [] + @player_planets.each do |planet| + planet_data.append(planet.info_update()) + end + { + 'type' => 'update', + 'domain' => 'player', + 'request_id' => request_id, + 'payload' => { + 'resources' => @player_resources, + 'planets' => planet_data, + 'ships' => @player_ships + } + } + end + + # Used to send regular updates for the game data. + def send_update + # Send an update of all of the player data and stuff + return if !connected? + begin + @socket.puts(update(nil).to_json) + rescue + @socket.close + end + end + + # Save a player to a file through marshaling it. + def save_to_file + File.open("game_data/#{@player_id}_player_.save", 'wb') do |fi| + # Load resources for the player: + fi.write(Marshal.dump(self)) + end + return @player_id + end + + # Load a player from a file. Requires the player id to load with as that is + # the file name. + def self.load_from_save(player_id) + File.open("game_data/#{player_id}_player_.save", 'rb') do |fi| + # Load resources for the player: + Marshal.load(fi.read) + end + end + + # Custom funciton to dump marshal data for players. We do not want to try to + # dump the socket... + def marshal_dump + { + player_id: @player_id, + player_planets: @player_planets, + player_ships: @player_ships, + home_planet: @home_planet, + faction: @faction, + player_resources: @player_resources + } + end + + # Custom function to load the marshaled data for the player. + # + # @param data [Hash] A hash containing the data. + def marshal_load(data) + @player_id = data[:player_id] + @player_planets = data[:player_planets] + @player_ships = data[:player_ships] + @home_planet = data[:home_planet] + @faction = data[:faction] + @player_resources = data[:player_resources] + end +end + + +# Errors for the player +class PlayerError < StandardError +end diff --git a/lib/game/ship.rb b/lib/game/ship.rb new file mode 100644 index 0000000..f62a603 --- /dev/null +++ b/lib/game/ship.rb @@ -0,0 +1,146 @@ +# Nathan Hinton 30 Dec 2025 +# Ship file for the game + +require 'securerandom' + +# Load the ships into a constant: +require 'yaml' +SHIP_DATA = File.open('lib/game/ships.yaml', 'r') do |fi| + YAML.safe_load(fi.read()) +end + +class Ship + + attr_reader :owner, :position + + # Ships are built by a player so we should *always* know what player is + # owning a ship + # + # @param player [Player] The player who owns the ship + # @param position_x [Float] The starting x position of the ship. + # @param position_y [Float] The starting y position of the ship. + # @param position_z [Float] The starting z position of the ship. + def initialize(player, position_x, position_y, position_z, name) + @owner = player + @ship_id = SecureRandom.hex + @position = [position_x, position_y, position_z] + @target = @position + @speed = 1 + @ship_info = validate_creation(name) + end + + # Validates that the ship name exists and it is unlocked by the player (Has + # the right buildings and tech to actually create). This means that you might + # be able to start a ship however when it is 'finished' if a required + # building has been removed it will fail to finish properly. In this case the + # player resources should be refunded (with some lost) and the ship creation + # will raise an error. + def validate_creation(name) + ship = SHIP_DATA[name] + raise ShipError, "invalid ship name '#{name}'" if ship.nil? + # Validate that the owner has the correct tech: + ship['required_tech'].each do |tech_needed| + if !(@owner.researched_tech.include? tech_needed) + raise ShipError, "Player needs the tech of '#{tech_needed}'" + end + end + # Validate the buildings: + ship['required_buildings'].each do |building_needed| + if !(@owner.buildings.include? building_needed) + raise ShipError, "Player needs the building of '#{building_needed}' to build this ship" + end + end + end + + # Teleport the ship to a given position. Really only should be used for admin + # stuff. + # + # @param position_x [Float] The new x position of the ship. + # @param position_y [Float] The new y position of the ship. + # @param position_z [Float] The new z position of the ship. + def teleport(position_x, position_y, position_z) + @position = [position_x, position_y, position_z] + end + + # Sets the target position of a ship. + # + # @param position_x [Float] The new x position of the ship. + # @param position_y [Float] The new y position of the ship. + # @param position_z [Float] The new z position of the ship. + def move(position_x, position_y, position_z) + @target = [position_x, position_y, position_z] + end + + # Contains the logic to move a ship + # + # @param delta_time [Float] The delta time elapsed in the game + def update(delta_time) + # Movement logic: + if @position == @target + # Do nothing + else + # Calculate the difference of the position and the target + x_dist = @target[0] - @position[0] + y_dist = @target[1] - @position[1] + z_dist = @target[2] - @position[2] + total_dist = Math.sqrt(x_dist**2 + y_dist**2 + z_dist**2) + speed = (@speed/total_dist) * delta_time + + new_x = @position[0] + (x_dist * speed) + new_y = @position[1] + (y_dist * speed) + new_z = @position[2] + (z_dist * speed) + + if x_dist > 0 # Going right + if new_x > @target[0] + new_x = @target[0] + end + elsif x_dist < 0 # Going left + if new_x < @target[0] + new_x = @target[0] + end + end + + if y_dist > 0 # Going right + if new_y > @target[1] + new_y = @target[1] + end + elsif y_dist < 0 # Going left + if new_y < @target[1] + new_y = @target[1] + end + end + + if z_dist > 0 # Going right + if new_z > @target[2] + new_z = @target[2] + end + elsif z_dist < 0 # Going left + if new_z < @target[2] + new_z = @target[2] + end + end + @position = [new_x, new_y, new_z] + end + end + + # Save a player to a file + def save_to_file + File.open("game_data/#{self.planet_id}.save", 'wb') do |fi| + # Load resources for the player: + fi.write(Marshal.dump(self)) + end + return self.planet_id + end + + # Load a player from a file + def self.load_from_save(fname) + File.open("game_data/#{fname}_ship_.save", 'rb') do |fi| + # Load resources for the player: + Marshal.load(fi.read) + end + end +end + +# Error class for ships +class ShipError < StandardError +end diff --git a/lib/game/ships.yaml b/lib/game/ships.yaml new file mode 100644 index 0000000..c4c3404 --- /dev/null +++ b/lib/game/ships.yaml @@ -0,0 +1,235 @@ +# Nathan Hinton 31 Dec 2025 +# This file will hold configuration for ships in the game. + +# Here is how to create a ship: +# +# name: +# classification: +# flagship/battleship/heavy cruiser/light cruiser/destroyer/frigate/fighter # Pick one +# faction: +# good/evil +# production: +# metal/crystals/fuel/etc +# build_time: A value that represents how long it takes to build. Standard shipyards will build at a rate of 100 per tick. This means that a value of 1000 would take (for one shipyard) 10 ticks to produce. +# upkeep: +# metal/crystals/fuel/etc +# base_stats: +# hp/shield +# armor/def +# att +# speed # Units per tick +# total_module_slots: integer # The total slots available on this ship +# module_slots: [] # List of module names found from modules.yaml +# required_tech: [] # List of all tech required to build the ship +# required_buildings: # List of all buildings required to build the ship +# +# +# +# +########################################## +############## ############## +############## GOOD FACTION ############## +############## ############## +########################################## +# + +Nebula Dominion: + classification: flagship + faction: good + production: + metal: 100,000 + crystals: 100,000 + fuel: 5,000 + time: 10,000 # In production power + upkeep: + metal: 100 + crystals: 100 + fuel: 150 + base_stats: + hp: 10,000 + shield: 10,000 + armor: 50 + def: 50 + att: 1,000 + speed: 0.2 + total_module_slots: 12 + module_slots: [] + required_tech: [Heavy Shield, Large Engine, Laser Cannons, Neoplated Armor] + required_buildings: [Space Construction Station] + +Void Breaker: + classification: battleship + faction: good + production: + metal: 70,000 + crystals: 70,000 + fuel: 3,500 + time: 7,000 + upkeep: + metal: 70 + crystals: 70 + fuel: 100 + base_stats: + hp: 7,000 + shield: 7,000 + armor: 35 + def: 35 + att: 700 + speed: 0.26 + total_module_slots: 8 + module_slots: [] + required_tech: [] + required_buildings: [] + + +Solar Sentinal: + classification: heavy cruiser + faction: good + production: + metal: 49,000 + crystals: 49,000 + fuel: 2,500 + time: 4,900 + upkeep: + metal: 49 + crystals: 49 + fuel: 73 + base_stats: + hp: 4,900 + shield: 4,900 + armor: 25 + def: 25 + att: 490 + speed: 0.34 + total_module_slots: 6 + module_slots: [] + required_tech: [] + required_buildings: [] + +Photon Protector: + classification: light cruiser + faction: good + production: + metal: 34,000 + crystals: 34,000 + fuel: 1,750 + time: 3,430 + upkeep: + metal: 34 + crystals: 34 + fuel: 51 + base_stats: + hp: 3,400 + shield: 3,400 + armor: 18 + def: 18 + att: 343 + speed: 0.44 + total_module_slots: 4 + module_slots: [] + required_tech: [] + required_buildings: [] + +Third Smallest: + classification: destroyer + faction: good + production: + metal: 23,800 + crystals: 23,800 + fuel: 1225 + time: 2,380 + upkeep: + metal: 24 + crystals: 24 + fuel: 36 + base_stats: + hp: 2,380 + shield: 2,380 + armor: 12 + def: 12 + att: 240 + speed: 0.57 + total_module_slots: 3 + module_slots: [] + required_tech: [] + required_buildings: [] + +Starlight: + classification: frigate + faction: good + production: + metal: 17,000 + crystals: 17,000 + fuel: 858 + time: 1,700 + upkeep: + metal: 17 + crystals: 17 + fuel: 25 + base_stats: + hp: 1,700 + shield: 1,700 + armor: 8 + def: 8 + att: 168 + speed: 0.74 + total_module_slots: 2 + module_slots: [] + required_tech: [] + required_buildings: [] + + +Starblade: + classification: fighter + faction: good + production: + metal: 11,900 + crystals: 11,900 + fuel: 600 + time: 1,100 + upkeep: + metal: 17 + crystals: 17 + fuel: 25 + base_stats: + hp: 1,190 + shield: 1,190 + armor: 8 + def: 8 + att: 168 + speed: 0.74 + total_module_slots: 1 + module_slots: [] + required_tech: [Engine_1, Body_1, Weapons_1] + required_buildings: [] + + +# +########################################## +############## ############## +############## EVIL FACTION ############## +############## ############## +########################################## +# +Void Bastion: + classification: flagship + faction: evil + production: + metal: 100,000 + crystals: 100,000 + fuel: 5,000 + time: 1,000 # In game ticks + upkeep: + metal: 100 + crystals: 100 + fuel: 150 + base_stats: + hp: 10,000 + shield: 10,000 + armor: 50 + def: 50 + att: 1,000 + total_module_slots: + module_slots: [] + required_tech: [Plastered glass] + required_buildings: [] diff --git a/lib/game_server.rb b/lib/game_server.rb deleted file mode 100644 index d2ce0f8..0000000 --- a/lib/game_server.rb +++ /dev/null @@ -1,8 +0,0 @@ -module GameServer - class App - def self.start - world = Simulation::World.new - Server::WebSocketServer.new(world).start - end - end -end diff --git a/lib/logging/context.rb b/lib/logging/context.rb deleted file mode 100644 index d17526f..0000000 --- a/lib/logging/context.rb +++ /dev/null @@ -1,18 +0,0 @@ -module GameServer - module Logging - module Context - def self.with(data) - old = Thread.current[:log_ctx] - Thread.current[:log_ctx] = data - yield - ensure - Thread.current[:log_ctx] = old - end - - def self.current - ctx = Thread.current[:log_ctx] - ctx ? ctx.map { |k,v| "#{k}=#{v} " }.join : "" - end - end - end -end diff --git a/lib/logging/formatter.rb b/lib/logging/formatter.rb deleted file mode 100644 index 08ab0dd..0000000 --- a/lib/logging/formatter.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "time" - -module GameServer - module Logging - class Formatter - def call(severity, time, _, msg) - "[#{time.utc.iso8601}] #{severity} #{Context.current}#{msg}\n" - end - end - end -end diff --git a/lib/logging/logger.rb b/lib/logging/logger.rb deleted file mode 100644 index e6806c6..0000000 --- a/lib/logging/logger.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "logger" - -module GameServer - module Logging - def self.setup - FileUtils.mkdir_p("log") - file = File.open("log/development.log", "a") - file.sync = true - - @logger = Logger.new(file) - @logger.level = Logger::DEBUG - @logger.formatter = Formatter.new - end - - def self.logger - @logger - end - end -end diff --git a/lib/protocol/errors.rb b/lib/protocol/errors.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/protocol/message.rb b/lib/protocol/message.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/protocol/validator.rb b/lib/protocol/validator.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/queries/player_queries.rb b/lib/queries/player_queries.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/queries/ship_queries.rb b/lib/queries/ship_queries.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/server/connection.rb b/lib/server/connection.rb deleted file mode 100644 index 2784df4..0000000 --- a/lib/server/connection.rb +++ /dev/null @@ -1,27 +0,0 @@ -require "json" - -module GameServer - module Server - class Connection - def initialize(ws, world) - @ws = ws - @world = world - @router = MessageRouter.new(world) - end - - def run - Logging.logger.info "connection opened" - - while msg = @ws.read - data = JSON.parse(msg) - response = @router.handle(data) - @ws.write(JSON.dump(response)) if response - end - rescue => e - Logging.logger.error e.message - ensure - Logging.logger.info "connection closed" - end - end - end -end diff --git a/lib/server/message_router.rb b/lib/server/message_router.rb deleted file mode 100644 index 5a610e0..0000000 --- a/lib/server/message_router.rb +++ /dev/null @@ -1,61 +0,0 @@ -module GameServer - module Server - class MessageRouter - def initialize(world) - @world = world - end - - def handle(data) - case data["type"] - when "hello" - hello - when "query" - query(data) - when "command" - command(data) - else - error("UNKNOWN_TYPE") - end - end - - def hello - { - type: "update", - domain: "world", - payload: { - time: @world.time, - game_speed: Settings::GAME_SPEED - } - } - end - - def query(data) - if data["domain"] == "player" - { - type: "update", - domain: "player", - payload: { money: 1000 } - } - else - error("UNKNOWN_DOMAIN") - end - end - - def command(data) - { - type: "update", - domain: "command", - payload: { status: "accepted" } - } - end - - def error(code) - { - type: "error", - domain: "system", - payload: { code: code } - } - end - end - end -end diff --git a/lib/server/websocket_server.rb b/lib/server/websocket_server.rb deleted file mode 100644 index 7b6dda3..0000000 --- a/lib/server/websocket_server.rb +++ /dev/null @@ -1,33 +0,0 @@ -require "async" -require "async/websocket" - -module GameServer - module Server - class WebSocketServer - def initialize(world) - @world = world - end - - def start - Async do |task| - task.async { simulation_loop } - - Async::WebSocket::Server.open("0.0.0.0", Settings::PORT) do |ws| - task.async { Connection.new(ws, @world).run } - end - end - end - - def simulation_loop - last = Time.now - loop do - now = Time.now - delta = (now - last) * Settings::GAME_SPEED - @world.advance(delta) - last = now - sleep Settings::SIMULATION_STEP - end - end - end - end -end diff --git a/lib/simulation/clock.rb b/lib/simulation/clock.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/simulation/combat.rb b/lib/simulation/combat.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/simulation/economy.rb b/lib/simulation/economy.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/simulation/movement.rb b/lib/simulation/movement.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/simulation/tick_loop.rb b/lib/simulation/tick_loop.rb deleted file mode 100644 index e69de29..0000000 diff --git a/lib/simulation/world.rb b/lib/simulation/world.rb deleted file mode 100644 index 8704002..0000000 --- a/lib/simulation/world.rb +++ /dev/null @@ -1,15 +0,0 @@ -module GameServer - module Simulation - class World - attr_reader :time - - def initialize - @time = 0.0 - end - - def advance(delta) - @time += delta - end - end - end -end diff --git a/lib/utils/math.rb b/lib/utils/math.rb deleted file mode 100644 index e69de29..0000000 diff --git a/spec/game/game_spec.rb b/spec/game/game_spec.rb new file mode 100644 index 0000000..a69f44b --- /dev/null +++ b/spec/game/game_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +# Nathan Hinton, 24 Dec 2025 +# Test file for the game. + +require 'rspec' +require 'socket' +require 'json' +require_relative '../../lib/game/game' # adjust the path if you keep the lib in another folder +require_relative '../../lib/game/planet' # adjust the path if you keep the lib in another folder + +module Game + RSpec.describe Game do + let(:player_defaults) do + { + 'credits' => 1000, + 'metal' => 200, + 'crystals' => 50, + 'fuel' => 300 + } + end + + let(:conf) do + { + 'name' => 'TestGalaxy', + 'width' => 1000, + 'height' => 800, + 'depth' => 500, + 'time' => 0.0, + 'player_defaults' => player_defaults, + 'players' => [], + 'planets' => [], + 'ships' => [] + } + end + + describe '#initialize' do + it 'creates a game instance with the supplied config' do + game = Game.new(conf) + expect(game.name).to eq('TestGalaxy') + expect(game.width).to eq(1000) + expect(game.height).to eq(800) + expect(game.depth).to eq(500) + expect(game.players).to be_empty + end + + it 'raises when a required key is missing' do + bad_conf = conf.dup.delete('name') + expect { Game.new(conf) }.not_to raise_error + + # Missing the mandatory “name” key + conf_without_name = conf.merge('name' => nil) + expect { Game.new(conf_without_name) }.to raise_error(ArgumentError) + end + end + + describe '#player_connect' do + let(:game) { Game.new(conf) } + let(:client_socket) { instance_double(TCPSocket) } + + context 'when the player already exists and is disconnected' do + let(:player_id) { 'player123' } + let(:existing_player) { instance_double('Player', player_id: player_id, connected?: false, connect: true) } + + before do + allow(game).to receive(:player_registered?).with(player_id).and_return(existing_player) + allow(existing_player).to receive(:connected?).and_return(false) + allow(existing_player).to receive(:connect).with(client_socket) + end + + it 're-connects the player and returns success' do + cmd = { 'payload' => { 'player_id' => player_id, 'faction' => 'good' } } + expect(game.player_connect(client_socket, cmd)).to eq('status' => 'success') + expect(existing_player).to have_received(:connect).with(client_socket) + end + end + + context 'when the player is already connected' do + let(:player_id) { 'dup' } + let(:existing_player) { instance_double('Player', player_id:, connected?: true) } + + before do + allow(game).to receive(:player_registered?).with(player_id).and_return(existing_player) + end + + it 'raises a GameError' do + cmd = { 'payload' => { 'player_id' => player_id, 'faction' => 'good' } } + expect { game.player_connect(client_socket, cmd) } + .to raise_error(GameError, 'Player already connected!') + end + end + + context 'when the player is new' do + let(:player_id) { 'newplayer' } + + before do + allow(game).to receive(:player_registered?).with(player_id).and_return(false) + allow(game).to receive(:create_player).with(player_id, 'evil').and_return( + instance_double('Player', player_id:, connect: true) + ) + end + + it 'creates a new player, connects them, and adds them to #players' do + cmd = { 'payload' => { 'player_id' => player_id, 'faction' => 'evil'} } + response = game.player_connect(client_socket, cmd) + expect(response).to eq('status' => 'success') + expect(game.players).to include(an_object_having_attributes(player_id:)) + end + end + + context 'when the command is missing a player_id' do + it 'raises an ArgumentError' do + expect { game.player_connect(client_socket, {}) } + .to raise_error(ArgumentError, /player_id missing/) + end + end + end + + describe '#create_player' do + let(:game) { Game.new(conf) } + let(:planet) { Planet.new(1, 1, 0) } + let(:planet2) { Planet.new(1, 1, 0) } + + before do + game.instance_variable_set('@planets', [planet, planet2]) + end + + it 'returns a Player instance with the requested id' do + expect(game.create_player('foo', 'good')).to be_a(Player) + end + end + + describe '#player_registered?' do + let(:game) { Game.new(conf) } + + it 'returns false if no players exist' do + expect(game.player_registered?('foo')).to be false + end + + it 'returns the Player when id matches' do + new_player = instance_double('Player', player_id: 'match') + game.instance_variable_set('@players', [new_player]) + allow(game).to receive(:players).and_return([new_player]) + expect(game.player_registered?('match')).to eq(new_player) + end + end + + describe '#info' do + it 'returns the game time in JSON format' do + game = Game.new(conf) + expect(JSON.parse(game.info(0))['payload']['time']).to eq(0.0) + end + end + + describe '#save_game_state' do + let(:game) { Game.new(conf) } + let(:file_content) { StringIO.new } + + before do + allow(File).to receive(:open).and_yield(file_content) + end + + it 'writes a YAML configuration containing the game time and attributes' do + game.save_game_state + yaml_output = YAML.safe_load(file_content.string) + + expect(yaml_output['name']).to eq('TestGalaxy') + expect(yaml_output['time']).to eq(0.0) + end + end + end +end diff --git a/spec/game/planet_spec.rb b/spec/game/planet_spec.rb new file mode 100644 index 0000000..5112f09 --- /dev/null +++ b/spec/game/planet_spec.rb @@ -0,0 +1,79 @@ +# Nathan Hinton +# Test file for the planets + +require 'game/planet' + + +RSpec.describe Planet do + let(:planet) {Planet.new(5, 5, 5)} + + before(:all) do + Dir.mkdir('game_data') unless Dir.exist?('game_data') + end + + after(:each) do + # Clean up any save file that might have been created + save_file = File.join('game_data', "#{planet.planet_id}.save") + File.delete(save_file) if File.exist?(save_file) + end + + describe '#initialize' do + it 'starts with an initial position inside the game board' do + planet.instance_variable_get(:@position).each do |pos| + expect(pos < 5).to be true + end + end + end + + describe '#claimable?' do + it 'is claimable when no owner' do + expect(planet.claimable?).to be true + end + + it 'is claimable if owned by another player and has no buildings' do + planet.instance_variable_set(:@owner, 'nathan') + expect(planet.claimable?).to be true + end + + it 'is not claimable if owned by another player and has buildings' do + planet.instance_variable_set(:@owner, 'nathan') + planet.instance_variable_set(:@buildings, ['command post']) + expect(planet.claimable?).to be false + end + end + + describe '#claim' do + it 'sets the owner of the planet to a given player' do + planet.claim('nathan') + expect(planet.owner).to eq 'nathan' + end + + it 'overwrites the player even if it was already set' do + planet.claim('nathan') + expect(planet.owner).to eq 'nathan' + planet.claim('leah') + expect(planet.owner).to eq 'leah' + end + end + + describe '#update' do + it 'process updates for the planet' do + end + end + + describe '#save_to_file' do + it 'saves the planet to a file' do + expect{planet.save_to_file}.to_not raise_error + # Check that save file was created. + expect(File.exist?(File.join('game_data', "#{planet.planet_id}_planet_.save"))).to be true + end + end + + describe '#load_from_save' do + it 'can load a planet from a save' do + planet.save_to_file + expect(File.exist?(File.join('game_data', "#{planet.planet_id}_planet_.save"))).to be true + expect(Planet.load_from_save(planet.planet_id)).to be_kind_of(Planet) + end + end +end diff --git a/spec/game/player_spec.rb b/spec/game/player_spec.rb new file mode 100644 index 0000000..ac74e6d --- /dev/null +++ b/spec/game/player_spec.rb @@ -0,0 +1,295 @@ +# Nathan Hinton 24 Dec 2025 + +# spec/player_spec.rb +require 'rspec' +require 'json' +require 'game/player' # adjust the path if needed + +RSpec.describe Player do + let(:player_id) { SecureRandom.uuid } + subject(:player) { Player.new(player_id, 'good') } + + # ------------------------------------------------------------------ + # Helpers / setup + # ------------------------------------------------------------------ + before(:all) do + Dir.mkdir('game_data') unless Dir.exist?('game_data') + end + + after(:each) do + # Clean up any save file that might have been created + save_file = File.join('game_data', "#{player_id}.save") + File.delete(save_file) if File.exist?(save_file) + end + + # ------------------------------------------------------------------ + # initialize + # ------------------------------------------------------------------ + describe '#initialize' do + it 'sets the id and empty arrays for planets and ships' do + expect(subject.player_id).to eq(player_id) + expect(subject.player_planets).to be_empty + expect(subject.player_ships).to be_empty + expect(subject.instance_variable_get(:@home_planet)).to be_nil + end + + it 'sets the faction correctly' do + subject = Player.new(player_id, 'good') + expect(subject.faction).to eq('good') + subject = Player.new(player_id, 'evil') + expect(subject.faction).to eq('evil') + end + + it 'raises errors when the faction does not match' do + expect{subject = Player.new(player_id, 'billy_bob_jo')}.to raise_error(PlayerError, 'Faction must be `good` or `evil`') + end + end + + # ------------------------------------------------------------------ + # create_new + # ------------------------------------------------------------------ + describe '#create_new' do + let(:credits) { 1000 } + let(:metal) { 500 } + let(:crystals) { 200 } + let(:fuel) { 300 } + + it 'stores the resources hash correctly' do + subject.create_new(credits, metal, crystals, fuel) + expect(subject.player_resources).to eq( + 'credits' => credits, + 'metal' => metal, + 'crystals' => crystals, + 'fuel' => fuel + ) + end + end + + # ------------------------------------------------------------------ + # connect / connected? + # ------------------------------------------------------------------ + describe '#connect and #connected?' do + let(:socket) { instance_double('TCPSocket', closed?: false, puts: nil) } + + it 'stores the socket' do + subject.connect(socket) + expect(subject.instance_variable_get(:@socket)).to eq(socket) + end + + context 'when the socket is nil' do + it 'returns false' do + subject.instance_variable_set(:@socket, nil) + expect(subject.connected?).to be false + end + end + + context 'when the socket is closed' do + before do + allow(socket).to receive(:closed?).and_return(true) + subject.connect(socket) + end + + it 'returns false' do + expect(subject.connected?).to be false + end + end + + context 'when the socket is open' do + before { subject.connect(socket) } + + it 'returns true' do + expect(subject.connected?).to be true + end + end + end + + # ------------------------------------------------------------------ + # claim_planet + # ------------------------------------------------------------------ + describe '#claim_planet' do + let(:planet) { double('planet', claimable?: true, owner: nil) } + + context 'when the planet is claimable' do + it 'adds the planet to the player and calls claim on the planet' do + expect(planet).to receive(:claim).with(subject) + subject.claim_planet(planet) + expect(subject.player_planets).to include(planet) + end + end + + context 'when the planet is not claimable' do + before { allow(planet).to receive(:claimable?).and_return(false) } + + it 'does nothing' do + expect(planet).not_to receive(:claim) + subject.claim_planet(planet) + expect(subject.player_planets).not_to include(planet) + end + end + end + + # ------------------------------------------------------------------ + # make_home_planet + # ------------------------------------------------------------------ + describe '#make_home_planet' do + context 'when the player owns the planet' do + let(:planet) { double('planet', owner: subject) } + + it 'sets the home planet' do + subject.make_home_planet(planet) + expect(subject.instance_variable_get(:@home_planet)).to eq(planet) + end + end + + context 'when the player does NOT own the planet' do + let(:planet) { double('planet', owner: nil) } + + it 'returns an error hash' do + result = subject.make_home_planet(planet) + expect(result).to eq( + 'type' => 'error', + 'payload' => 'Planet must be owned by the player' + ) + end + end + end + + # ------------------------------------------------------------------ + # update + # ------------------------------------------------------------------ + describe '#update' do + let(:credits) { 1000 } + let(:metal) { 500 } + let(:crystals) { 200 } + let(:fuel) { 300 } + let(:request_id) { 42 } + + before do + subject.create_new(credits, metal, crystals, fuel) + subject.player_planets << double('planet') + subject.player_ships << double('ship') + end + + it 'returns a hash in the expected format' do + expected = { + 'type' => 'update', + 'domain' => 'player', + 'request_id' => request_id, + 'payload' => { + 'resources' => subject.player_resources, + 'planets' => subject.player_planets, + 'ships' => subject.player_ships + } + } + expect(subject.update(request_id)).to eq(expected) + end + end + + # ------------------------------------------------------------------ + # send_update + # ------------------------------------------------------------------ + describe '#send_update' do + let(:socket) { instance_double('TCPSocket', closed?: false, puts: nil) } + + before { subject.connect(socket) } + + context 'when connected' do + it 'sends the update JSON' do + expect(socket).to receive(:puts).with(kind_of(String)) + subject.send_update + end + end + + context 'when not connected' do + before { subject.instance_variable_set(:@socket, nil) } + + it 'does nothing' do + expect { subject.send_update }.not_to raise_error + end + end + + context 'when socket.puts raises an exception' do + before do + allow(socket).to receive(:puts).and_raise(StandardError) + allow(socket).to receive(:close) + end + + it 'closes the socket' do + expect(socket).to receive(:close) + subject.send_update + end + end + end + + # ------------------------------------------------------------------ + # save_to_file / load_from_save + # ------------------------------------------------------------------ + describe 'file persistence' do + let(:credits) { 1000 } + let(:metal) { 500 } + let(:crystals) { 200 } + let(:fuel) { 300 } + + before do + subject.create_new(credits, metal, crystals, fuel) + end + + it 'writes a marshaled file and can read it back' do + file_id = subject.save_to_file + expect(file_id).to eq(player_id) + + loaded_player = Player.load_from_save(player_id) + expect(loaded_player).to be_a(Player) + expect(loaded_player.player_id).to eq(player_id) + expect(loaded_player.player_resources).to eq( + 'credits' => credits, + 'metal' => metal, + 'crystals' => crystals, + 'fuel' => fuel + ) + end + end + + # ------------------------------------------------------------------ + # marshal_dump / marshal_load + # ------------------------------------------------------------------ + describe '#marshal_dump and #marshal_load' do + let(:credits) { 1000 } + let(:metal) { 500 } + let(:crystals) { 200 } + let(:fuel) { 300 } + + before do + subject.create_new(credits, metal, crystals, fuel) + end + + it 'returns a hash with the expected keys' do + dump = subject.marshal_dump + expect(dump).to include( + player_id: subject.player_id, + player_resources: subject.player_resources + ) + end + + it 'restores the player via marshal_load' do + dump = subject.marshal_dump + # Create a brand‑new instance and load the dumped data + new_player = Player.new('dummy-id', 'good') + new_player.marshal_load(dump) + + expect(new_player.player_id).to eq(subject.player_id) + expect(new_player.player_resources).to eq(subject.player_resources) + end + end +end# Nathan Hinton 24 Dec 2025 +# Test file for the player + +require 'game/player' + +RSpec.describe Player do + describe '#initialize' do + it 'initializes with a player id' do + + end + end +end diff --git a/spec/game/ship_spec.rb b/spec/game/ship_spec.rb new file mode 100644 index 0000000..a7beb6a --- /dev/null +++ b/spec/game/ship_spec.rb @@ -0,0 +1,106 @@ +# Nathan Hinton 30 Dec 2025 +# Test file for ships + +require 'game/ship' + +RSpec.describe Ship do + let(:ship) {Ship.new('nathan', 0, 2, 4, 'Starlight')} + describe '#initialize' do + it 'Creates a ship with a player and a position' do + expect(ship.owner).to eq 'nathan' + expect(ship.position).to eq [0, 2, 4] + end + + it 'Fails to create a ship when the name is invalid' do + expect{Ship.new('nathan', 0, 2, 4, 'billy bob joe')}.to raise_error ShipError, "invalid ship name 'billy bob joe'" + end + + it 'Fails when the player does not have the required tech' do + nathan = instance_double('Player', researched_tech: ['Light Shield', 'Basic Engine']) + expect{Ship.new(nathan, 0, 2, 4, 'Nebula Dominion')}.to raise_error ShipError, "Player needs the tech of 'Heavy Shield'" + end + + it 'Fails when the player does not have the required buildings' do + nathan = instance_double('Player', researched_tech: ['Heavy Shield', 'Large Engine', 'Laser Cannons', 'Neoplated Armor'], buildings: []) + expect{Ship.new(nathan, 0, 2, 4, 'Nebula Dominion')}.to raise_error ShipError, "Player needs the building of 'Space Construction Station' to build this ship" + end + end + + describe '#teleport' do + it 'Changes the coordinates of the ship immediately' do + expect(ship.position).to eq [0, 2, 4] + ship.teleport(4, 2, 0) + expect(ship.position).to eq [4, 2, 0] + end + end + + describe '#move' do + it 'Sets the target of the ship movement' do + ship.move(1, 3, 5) + expect(ship.instance_variable_get(:@target)).to eq [1, 3, 5] + end + end + + describe '#update' do + it 'Does not move when target is eq to position' do + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [0, 2, 4] + end + + describe 'Moves a single ship at the correct speed when target is different from position' do + it 'in positive x' do + ship.move(1, 2, 4) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [1.0, 2.0, 4.0] + end + + it 'in negative x' do + ship.move(-1, 2, 4) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [-1.0, 2.0, 4.0] + end + + it 'in positive y' do + ship.move(0, 3, 4) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [0.0, 3.0, 4.0] + end + + it 'in negative y' do + ship.move(0, 1, 4) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [0.0, 1.0, 4.0] + end + + it 'in positive z' do + ship.move(0, 2, 5) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [0.0, 2.0, 5.0] + end + + it 'in negative z' do + ship.move(0, 2, 3) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [0.0, 2.0, 3.0] + end + + it 'in multiple dimensions' do + ship.move(-1, 3, 3) + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [-0.5773502691896258, 2.5773502691896257, 3.4226497308103743] + expect{ship.update(1)}.to_not raise_error + expect(ship.position).to eq [-1, 3, 3] + end + + end + + it 'Moves a group of ships at the slowest ship speed' do + end + end + + describe '#save_to_file' do + end + + describe '#load_from_save' do + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..dfba987 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,99 @@ +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + config.order = :random +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end