Basic working with ships partly implimented
This commit is contained in:
@@ -7,3 +7,4 @@
|
||||
vendor/*
|
||||
Gemfile.lock
|
||||
.bundle/*
|
||||
game_data/*
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env ruby
|
||||
require_relative "../config/environment"
|
||||
require_relative "../lib/app"
|
||||
|
||||
GameServer::App.start
|
||||
|
||||
@@ -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 }
|
||||
@@ -1,3 +0,0 @@
|
||||
require_relative "../lib/logging/logger"
|
||||
|
||||
GameServer::Logging.setup
|
||||
@@ -1,5 +0,0 @@
|
||||
module Settings
|
||||
PORT = 8080
|
||||
GAME_SPEED = 1.0
|
||||
SIMULATION_STEP = 0.05
|
||||
end
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
module GameServer
|
||||
module Domain
|
||||
class Player
|
||||
attr_reader :money
|
||||
|
||||
def initialize
|
||||
@money = 1000
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -1,8 +0,0 @@
|
||||
module GameServer
|
||||
class App
|
||||
def self.start
|
||||
world = Simulation::World.new
|
||||
Server::WebSocketServer.new(world).start
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user