Basic working with ships partly implimented

This commit is contained in:
2026-01-07 23:11:46 -07:00
parent 1ec24fbf12
commit 6e914467ed
49 changed files with 2079 additions and 220 deletions
+1
View File
@@ -7,3 +7,4 @@
vendor/*
Gemfile.lock
.bundle/*
game_data/*
+1
View File
@@ -0,0 +1 @@
--require spec_helper
+34
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require_relative "../lib/app"
GameServer::App.start
-7
View File
@@ -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 }
-3
View File
@@ -1,3 +0,0 @@
require_relative "../lib/logging/logger"
GameServer::Logging.setup
-5
View File
@@ -1,5 +0,0 @@
module Settings
PORT = 8080
GAME_SPEED = 1.0
SIMULATION_STEP = 0.05
end
+2 -1
View File
@@ -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"
}
```
+14
View File
@@ -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
+60
View File
@@ -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
View File
View File
View File
-11
View File
@@ -1,11 +0,0 @@
module GameServer
module Domain
class Player
attr_reader :money
def initialize
@money = 1000
end
end
end
end
View File
View File
View File
View File
+163
View File
@@ -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: []
+400
View File
@@ -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
+40
View File
@@ -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: []
+75
View File
@@ -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
+156
View File
@@ -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
+146
View File
@@ -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
+235
View File
@@ -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: []
-8
View File
@@ -1,8 +0,0 @@
module GameServer
class App
def self.start
world = Simulation::World.new
Server::WebSocketServer.new(world).start
end
end
end
-18
View File
@@ -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
-11
View File
@@ -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
-19
View File
@@ -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
View File
View File
View File
View File
View File
-27
View File
@@ -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
-61
View File
@@ -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
-33
View File
@@ -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
View File
View File
View File
View File
View File
-15
View File
@@ -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
View File
+172
View File
@@ -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
+79
View File
@@ -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
+295
View File
@@ -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 brandnew 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
+106
View File
@@ -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
+99
View File
@@ -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