Compare commits

...

11 Commits

41 changed files with 3082 additions and 987 deletions
+4
View File
@@ -4,3 +4,7 @@
**/logs
**/*tmp*
**/coverage
vendor/*
Gemfile.lock
.bundle/*
game_data/*
View File
+4
View File
@@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "async"
gem "async-websocket"
+43 -14
View File
@@ -1,5 +1,9 @@
# Goals:
# Note
This is now directed at the `src` folder. The `game` folder is old code and is
kept for example stuff.
# Goals:
Make a multiplayer game that will run in the browser. This game should be a
planet conquest type game where you can create a lobby and then players can
@@ -12,7 +16,8 @@ bonuses. Some can produce ships faster, others will produce more resources and
in others you can find bonuses for different kinds of ships. I want to make it
so that you can also upgrade the planets to improve the value of them.
The maximum player count in a game will be 20 however I want this to be scalable.
The maximum player count in a game will be 20 however I want this to be
scalable.
# Thoughts:
@@ -30,7 +35,10 @@ gone. This means that the worlds need to be persistent.
As a follow up that means that each world will be always running. This means
that if there is a main loop for the game it should be very lightweight so that
it does not consume massive amounts of resources.
it does not consume massive amounts of resources. Another thing that I want to
have is that the game speed will change based on the number of players
online. For example, if there are 4 players in the game and all 4 are active I
want to have the game move faster so that they can play together better.
I think that each universe should be randomly generated that way the players
have to explore it.
@@ -39,14 +47,20 @@ I do eventually want to have AI players however for now I just want human
players. Make sure that you help me to keep this easy as I am programming.
Here are the resources that I am thinking of for now:
- Food
- Metal
- Fuel/energy
- Food (Local to planet)
- Energy (Local to planet)
- Metal (Global)
- Fuel (Global)
- Crystals (Global)
- Galaxy Credits (Global)
How do we have resources travel be twee planets? I am not sure yet. Maybe have
How do we have resources travel be tween planets? I am not sure yet. Maybe have
another type of ship called a transport that can transport resources. This
should be automated though so that needed resources are transported
auto-magically.
auto-magically. Another option is to have some resources like metal, crystals,
and Galaxy Credits to be global while things like food and energy are local to
each planet. This way you can build production buildings on each planet.
I also want there to be some form of tech tree that the player can choose and
can research. Maybe add some rare resources.
@@ -89,10 +103,25 @@ security however the server does need to be secure.
The `Universe` class will contain everything in the universe. This means planets, ships, and other stelar objects. This will be the API that I can think of:
- universe/
- ships/
- create/
- update/
- move/
## User accesible API
This is the API that the players will be able to access:
```
universe/
- players/
- planets/
- update/
- buildings
- fleets/
- fleet1/
- ships/
- fleet2/
- ships/
ships/
- tech/
```
## Admin/server API
I want them to be able to do anything in the game incliding normally illiegial
actions.
+3 -4
View File
@@ -7,7 +7,6 @@ task :test do
begin
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
ENV['RUN_ALL_TESTS'] = 'true'
Rake::Task['spec'].invoke
rescue LoadError
puts 'Failed to load RSpec to run tests... Try installing rspec'
@@ -17,19 +16,19 @@ end
desc 'Run the server for clients to connect to'
task :serve do
raise NotImplementedError.new('Server task not yet implimented!')
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 *.rb'
res = sh 'yard doc lib'
sh 'yard stats lib --list-undoc'
end
desc 'Create code coverage'
task :coverage do
ENV['COVERAGE'] = 'true'
Rake::Task[:test].invoke
end
end
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
require_relative "../lib/app"
GameServer::App.start
+104
View File
@@ -0,0 +1,104 @@
This is a list of all the commands that are available from the client to the
server along with the expected response.
# Message types
## Client
### Hello
Sent during initiali connection phase.
### Query
Query information from the server about a specific topic
### Command
Request an action to be performed on the server
## Server
### Hello
Responses and further queries during the connection phase. Example is asking
what faction they want to be.
### Update
Response to a query or a command. Examples would be responding to a query of
how much money the player has. A response updating the target positon of a ship
and it's location.
This is also used to provide updates about the game state. There are periodic
updates that define where everything should be. This is the type used there.
### Event
This is an event that the client did not create or ask for. For example, a
battle starting, a building finishing, a planet found.
### Error
A response sent when the requested action can not be completed. This is likely
to be a frequent response to things like not having enough resources.
# Full message definitions
For all communication with the server the player you need the player id
## Hello
For the hello message neither key is optional for connecting. They are both
required. If a new player is created then it does not matter what the faction
key is (as long as it is a valid faction) however when connecting an existing
player the faction key *must* match the key stored on the server or a
connection error will happen.
| Domain | Payload keys | Expected response |
|:-------|:-------------------|:----------------------------------------------------------------------|
| Player | connect: player_id | An update with payload success OR an update with a payload of faction |
| | faction: faction | An update to the player (when creating a player) that has the faction |
| | | |
| | | |
## Queries
For queries the keys just need to exist. There are not any values for the
payload keys. You can also apply as many keys in a single request as you would
like. All parts of the message should be in snake_case. All of the values for
the keys may be whatever you wish, they are discarded by the server though.
| Domain | Payload keys | Expected response |
|:-------|:-------------|--------------------------------------------------------------------|
| Game | time | The current game time. |
| | size | The current size of the game universe. |
| Player | resources | The resources the player has |
| | planets | The planets the player can see (All planets owned, and discovered) |
| | buildings | The buildings the player has |
| | ships | The ships the player has |
| | construction | The builds in progress the player has |
| | | |
| | | |
## Commands
This lists the domain and the payload keys required as well as the expected
value.
| Domain | Payload keys | Expected response |
|:-------|:----------------------------------------------------|:-----------------------------------------------|
| Game | resign | An update with payload success. |
| Player | build: type [tech, building, ship], name: item name | An update with payload success. |
| | move: type [ship], name: item\_id | An update with the new target of the item. |
| | equip: item\_id, target: item\_id | An update with the stats of the targeted item. |
| | | |
| | | |
| | | |
| | | |
| | | |
+84
View File
@@ -0,0 +1,84 @@
# Chatting with GPT:
I think that I will modify some of how I am doing things. We will have the game do things differently. We will have a main loop that will process events and then we will use delta time and a `game speed` variable to control things.
Here is my newly defined API:
# API
Transport: TCP Sockets
Encoding: JSON
Direction: Bidirectional
Every message has:
```
{
"type": "...",
"domain": "...",
"request_id": integer
"payload": {}
"player_id"
}
```
## Message types:
### `hello`
- Direction: Client --> Server
- Purpose: initiates the connection and has authentication
- Behavior:
- Must be responded to immediately
- Response must be an `update` type with a full update (domain `all`)
### `query`
- Direction: Client --> Server
- Purpose: request read-only data
- Behavior:
- Must be responded to immediately
- Does not mutate server state
- Response must be an `update` type with the requested domain
### `command`
- Direction: Client --> Server
- Purpose: Request a state change
- Behavior:
- Server validates the command immediately
- If the command is valid the server applies the command and acknowledges with an `event` with the resulted action.
- If invalid the server responds with an `error` and includes the command and an error message.
### `update`
- Direction: Server --> Client
- Purpose: Deliver the game state to the client
- Behavior:
- For a full state update (ie player login) the `all` domain is used. Otherwise it is a subdomain.
- Used as a response to `hello`, `query`.
- Full updates are sent periodically for client synchnorization.
### `event`
- Direction: Server --> Client
- Purpose: Notify the client of events
- Behavior:
- Represents something that *happened*
- Not a full state snapshot
- Can not be requested by the client
### `error`
- Direction: Server --> Client
- Purpose: Report a failure to process a `query` or `command`
- Behavior:
- Does not mutate state
- Describe why the request failed.
## Notes
**The server is authoritative for all game state. Clients may not infer or simulate authoritative outcomes beyond visual interpolation.**
-14
View File
@@ -1,14 +0,0 @@
# Nathan Hinton 1 Oct 2025
# This file basiacally creates a universe and runs a simulated game in it.
require './universe'
require './player'
universe = Universe.new('test universe', universe_size: 3)
universe.create_universe
universe.print_universe()
planets = universe.planets
player = Player.new('test player')
puts player.name
-17
View File
@@ -1,17 +0,0 @@
# Nathan Hinton 2 Oct 2025
# Does not work sadly...
require 'logger'
module Log
@log = Logger.new('game.log')
@log.level = Logger::INFO
def self.logger
@logger
end
def logger
Log.logger
end
end
-32
View File
@@ -1,32 +0,0 @@
# Nathan Hinton 1 Oct 2025
PLANET_PRODUCTION_RESOURCES = [:ships]
# This class holds information about a planet.
class Planet
attr_reader :owner
# Create a planet
#
# @param random [Random] The random generator for the game (ensures seeds can be duplicated)
def initialize(random)
@random = random
@prod_rate = @random.rand
@resource = PLANET_PRODUCTION_RESOURCES[(@random.rand * PLANET_PRODUCTION_RESOURCES.length).floor]
@owner = nil
end
# Change the ownership of a planet. Should check for things like the planet
# has no defenders left and other fun stuff (later)
#
# @param owner [Player] The owner of the planet
# @return [Boolean] If the operation was successful or not
def change_owner(owner)
# For now only unclaimed planets can be owned
if @owner.nil?
@owner = owner
return true
end
return false
end
end
-14
View File
@@ -1,14 +0,0 @@
# Nathan Hinton 2 Oct 2025
# Class for the player. For now does not do much.
class Player
attr_reader :name
# initializes with just the player name.
#
# @param name [String] The name of the player
def initialize(name)
@name = name
end
end
-47
View File
@@ -1,47 +0,0 @@
# Nathan Hinton 2 Oct 2025
# A helper class for storing and converting universe point formats:
class Position
attr_reader :x_pos, :y_pos, :a, :b, :c
def initialize(*args)
if args.length == 2
# initialize with x, y position:
if args.sum.odd?
raise ArgumentError.new('A position created in (x, y) must sum to an even number!')
end
@x_pos = args[0]
@y_pos = args[1]
elsif args.length == 3
# initialize with a, b, c position
raise NotImplementedError.new('(a, b, c) positioning not implimented!')
else
raise ArgumentError.new('Creation of a position requires two or three integer args for a position.')
end
end
# Returns the corrdinates in the 2d plane in a 3 axis notation.
#
# @return [Array<Integer>] The (a, b, c) position
def two_d_hexagonal
return [a, b, c]
end
# Returns the coordinates in the 2d plane
#
# @return [Array<Integer>] The (x, y) position
def two_d_cartesian
return [x_pos, y_pos]
end
# Returns the string formatted version
#
# @return [String] Returns a string representing the point.
def to_s
return "(#{@x_pos}, #{y_pos})"
end
def ==(other)
return other.x_pos == @x_pos && other.y_pos == @y_pos
end
end
-60
View File
@@ -1,60 +0,0 @@
# Nathan Hinton 2 Oct 2025
# This class will be the container for ships. For now they are all the same
class Ship
def initialize(x_pos, y_pos)
@position = [x_pos, y_pos]
@speed = 0.1
end
# Called every game tick
def tick
if @moving != [0, 0]
end
end
# Move function. This will move a ship. For now you can only move one hop,
# there is no way to move more than one at the moment. This position should
# be only *one* spot away from where we are.
#
# @param x_pos [Integer] The target x position
# @param y_pos [Integer] The target y position
def move(x_pos, y_pos)
#Validate coordinates:
if (x_pos + y_pos).odd?
raise ArgumentError.new('The x, y coordinate for movement must add to be even!')
end
# Ensure we are not too far:
if ((x_pos + y_pos) - @position.sum).abs != 2
raise ArgumentError.new('The x, y coordinates for moevement must be to an adjcent space!')
end
end # Function move
# Move_direction. Takes a direction and then sets the moving variable equal that direction.
#
# @param direction [Integer] A value between (0, 5) that determines the direction (think unit circle)
def move_direction(direction)
if (direction < 0 || direction > 5)
raise ArgumentError.new('When moving using direction based movement, x must be between (0..5)!')
end
# This means that the direction can range from 0 to 5 (think of unit circle)
case direction
when 0
@moving = [2, 0] # Right
when 1
@moving = [1, 1] # Right up
when 2
@moving = [-1, 1] # Left up
when 3
@moving = [-2, 0] # Left
when 4
@moving = [-1, -1] # Left down
when 5
@moving = [1, -1] # Right down
end
end # Function move_direction
end
-2
View File
@@ -1,2 +0,0 @@
# frozen_string_literal: true
# Nathan Hinton 2 Oct 2025
-44
View File
@@ -1,44 +0,0 @@
# Nathan Hinton 2 Oct 2025
require './planet'
require './player'
RSpec.describe Planet do
let(:seed) { 12345 }
let(:random) { Random.new(seed) }
let(:planet) { Planet.new(random) }
let(:player_1) {Player.new('tester 01') }
let(:player_2) {Player.new('tester 02') }
describe '#initialize' do
it 'stores the random off' do
expect(planet.instance_variable_get(:@random)).to eq(random)
end
it 'creates a production rate' do
expect(planet.instance_variable_get(:@prod_rate)).to eq(Random.new(seed).rand)
end
it 'sets a resource' do
expect(planet.instance_variable_get(:@resource)).to eq(PLANET_PRODUCTION_RESOURCES[(Random.new(seed).rand * PLANET_PRODUCTION_RESOURCES.length).floor])
end
it 'has a nil owner' do
expect(planet.instance_variable_get(:@owner)).to eq nil
end
end # describe #initialize
describe '#change_owner' do
it 'allows an ownership change when the current owner is nil' do
expect(planet.change_owner(player_1)).to eq(true)
expect(planet.instance_variable_get(:@owner)).to eq player_1
end
it 'does not change when already owned' do
planet.change_owner(player_2)
expect(planet.change_owner(player_1)).to eq(false)
expect(planet.instance_variable_get(:@owner)).to eq player_2
end
end
end
-72
View File
@@ -1,72 +0,0 @@
# frozen_string_literal: true
require './position'
RSpec.describe Position do
let(:even_pair) { [4, 6] } # 4+6 = 10 → even
let(:odd_pair) { [3, 4] } # 3+4 = 7 → odd
describe 'initialisation' do
context 'with two arguments' do
it 'stores the coordinates when the sum is even' do
pos = Position.new(*even_pair)
expect(pos.x_pos).to eq(even_pair[0])
expect(pos.y_pos).to eq(even_pair[1])
end
it 'raises ArgumentError when the sum is odd' do
expect { Position.new(*odd_pair) }
.to raise_error(ArgumentError, /must sum to an even number/)
end
end
context 'with three arguments' do
it 'raises NotImplementedError' do
expect { Position.new(1, 2, 3) }
.to raise_error(NotImplementedError, /positioning not implimented/)
end
end
context 'with an unsupported number of arguments' do
it 'raises ArgumentError when no arguments are supplied' do
expect { Position.new }
.to raise_error(ArgumentError, /requires two or three integer args/)
end
it 'raises ArgumentError when too many arguments are supplied' do
expect { Position.new(1, 2, 3, 4) }
.to raise_error(ArgumentError, /requires two or three integer args/)
end
end
end
describe '#two_d_cartesian' do
it 'returns the original (x, y) pair' do
pos = Position.new(*even_pair)
expect(pos.two_d_cartesian).to eq(even_pair)
end
end
describe '#two_d_hexagonal' do
it 'returns an array of nil values for a, b, c' do
pos = Position.new(*even_pair)
expect(pos.two_d_hexagonal).to eq([nil, nil, nil])
end
end
describe '#to_s' do
it 'puts out the coordinates' do
expect(Position.new(*even_pair).to_s).to eq('(4, 6)')
end
end
describe '#==' do
it 'returns false when the positions differ' do
expect(Position.new(*even_pair) == Position.new(6, 8)).to eq(false)
end
it 'returns true when the positions are the same' do
expect(Position.new(*even_pair) == Position.new(4, 6)).to eq(true)
end
end
end
-88
View File
@@ -1,88 +0,0 @@
# frozen_string_literal: true
require_relative '../ship' # <-- adjust to the actual path of ship.rb
RSpec.describe Ship do
let(:ship) { Ship.new(0, 0) }
describe '#initialize' do
it 'stores the given position' do
expect(ship.instance_variable_get(:@position)).to eq([0, 0])
end
it 'sets the speed to 0.1' do
expect(ship.instance_variable_get(:@speed)).to eq(0.1)
end
it 'does not set @moving until a direction is chosen' do
expect(ship.instance_variable_get(:@moving)).to be_nil
end
end
describe '#tick' do
it 'does nothing (no exception) when called' do
expect { ship.tick }.not_to raise_error
end
end
describe '#move' do
context 'when coordinates are valid (even sum, adjacent)' do
it 'accepts positions that satisfy the rules' do
expect { ship.move(1, 1) }.not_to raise_error # sum 2
expect { ship.move(2, 0) }.not_to raise_error # sum 2
expect { ship.move(0, 2) }.not_to raise_error # sum 2
expect { ship.move(-1, -1) }.not_to raise_error # sum -2
end
end
context 'when the sum is odd' do
it 'raises an ArgumentError' do
expect { ship.move(0, 1) }.to raise_error(
ArgumentError,
/must add to be even/
)
end
end
context 'when the target is not adjacent' do
it 'raises an ArgumentError' do
expect { ship.move(3, 3) }.to raise_error(
ArgumentError,
/must be to an adjcent space/
)
expect { ship.move(0, 0) }.to raise_error(
ArgumentError,
/must be to an adjcent space/
)
end
end
end
describe '#move_direction' do
it 'accepts direction values 0..5 and sets @moving correctly' do
mapping = {
0 => [2, 0],
1 => [1, 1],
2 => [-1, 1],
3 => [-2, 0],
4 => [-1, -1],
5 => [1, -1]
}
mapping.each do |dir, expected|
expect { ship.move_direction(dir) }.not_to raise_error
expect(ship.instance_variable_get(:@moving)).to eq(expected)
end
end
it 'raises ArgumentError for invalid directions' do
expect { ship.move_direction(-1) }.to raise_error(
ArgumentError,
/must be between \(0..5\)/
)
expect { ship.move_direction(6) }.to raise_error(
ArgumentError,
/must be between \(0..5\)/
)
end
end
end
-12
View File
@@ -1,12 +0,0 @@
# If you are getting an error `uninitialized constant StringIO` make sure that
# your code is in an `it` block.
# Helper that captures STDOUT.
def capture_stdout
output = StringIO.new
$stdout = output
yield
output.string
ensure
$stdout = STDOUT
end
-38
View File
@@ -1,38 +0,0 @@
# Nathan Hinton 2 Oct 2025
require './universe_array'
RSpec.describe UniverseArray do
describe '#[]' do
it 'works with indexes' do
uni_arr = UniverseArray.new()
uni_arr[0] = []
uni_arr[0][0] = 'hi'
expect(uni_arr[0][0]).to eq 'hi'
end
it 'works with positions' do
uni_arr = UniverseArray.new()
uni_arr[0] = []
uni_arr[0][0] = 'hi'
expect(uni_arr[Position.new(0, 0)]).to eq 'hi'
end
end
describe '#[]=' do
it 'works with indexes' do
uni_arr = UniverseArray.new()
uni_arr[0] = []
uni_arr[0][0] = 'hi'
expect(uni_arr[0][0]).to eq 'hi'
end
it 'works with positions' do
uni_arr = UniverseArray.new()
uni_arr[0] = []
uni_arr[Position.new(0, 0)] = 'hi'
expect(uni_arr[Position.new(0, 0)]).to eq 'hi'
end
end
end
-146
View File
@@ -1,146 +0,0 @@
# frozen_string_literal: true
require './universe'
require './planet'
require './position'
require './universe_array'
require './spec/stdout_helper'
RSpec.describe Universe do
let(:name) { 'Test Universe' }
let(:universe_size) { 3 }
let(:seed) { 12345 }
subject(:universe) do
Universe.new(
name,
universe_size: universe_size,
seed: seed,
create_chances: { planet: 1 } # force a planet at every valid spot
)
end
describe '#initialize' do
it 'stores all expected instance variables' do
expect(universe.name).to eq(name)
expect(universe.instance_variable_get(:@universe_size)).to eq(universe_size)
expect(universe.instance_variable_get(:@seed)).to eq(seed)
end
it 'creates a deterministic Random object when a seed is given' do
rng = universe.instance_variable_get(:@random)
expect(rng).to be_a(Random)
expect(rng.rand(100)).to eq(Random.new(seed).rand(100))
end
it 'initializes the random if no seed is passed in' do
uni = Universe.new(name)
rng = uni.instance_variable_get(:@random)
expect(rng).to be_a(Random)
end
end
describe '#create_universe' do
end
describe '#distance' do
it 'is symmetric' do
a = Position.new(0, 0)
b = Position.new(4, 2)
expect(universe.distance(a, b)).to eq(universe.distance(b, a))
end
it 'returns 0 when both points are identical' do
p = Position.new(1, 1)
expect(universe.distance(p, p)).to eq(0)
end
it 'correctly computes known hex distances' do
p0 = Position.new(0, 0)
p1 = Position.new(2, 0)
p2 = Position.new(4, 2)
p3 = Position.new(2, 2)
# (0,0) to (2,0) → 1
expect(universe.distance(p0, p1)).to eq(1)
# (0,0) to (4,2) → 3
expect(universe.distance(p0, p2)).to eq(3)
# (0,0) to (2,2) → 2
expect(universe.distance(p0, p3)).to eq(2)
end
end
describe '#map generation' do
it 'creates the expected dimensions' do
universe.create_universe
map = universe.instance_variable_get(:@universe_map)
# Each valid (x,y) pair inside the radius contains a Planet
# (our stub) and every other entry is nil.
expect(map).to be_a(UniverseArray)
# Check how may nodes were created:
expect(map.flatten.compact.length).to eq(19) # 1 + 1*6 + 2*6
end
it 'contains a planet at every valid point when create_chances is 1' do
universe.create_universe
map = universe.instance_variable_get(:@universe_map)
map.each do |row|
row.each do |idx|
# The indexes are allowed to be nil
if !idx.nil?
expect(idx).to be_a(Planet)
end
end
end
end
it 'is deterministic when the same seed is used' do
first_universe = Universe.new(
name,
universe_size: universe_size,
seed: seed,
create_chances: { planet: 0.1 }
)
first_map = first_universe.instance_variable_get(:@universe_map)
second_universe = Universe.new(
name,
universe_size: universe_size,
seed: seed,
create_chances: { planet: 0.1 }
)
second_map = second_universe.instance_variable_get(:@universe_map)
expect(first_map).to eq(second_map)
end
end
describe '#print_universe' do
it 'prints the center point as “O”' do
out = capture_stdout do
universe.create_universe
universe.print_universe
end
expect(out).to include('O')
end
it 'can print blank spaces for nothing' do
out = capture_stdout do
uni = Universe.new(name, create_chances: {planet: 0.0})
uni.create_universe
uni.print_universe
end
expect(out).to include('O')
expect(out).to include('- -')
end
end
describe '#planets' do
it 'lists all of the generated planets' do
universe.create_universe
expect(universe.planets.length).to eq(19)
end
end
end
-350
View File
@@ -1,350 +0,0 @@
# Nathan Hinton 1 Oct 2025
require 'logger'
require './planet'
require './position'
require './universe_array'
# Universe class. Holds a universe and provides the api for it. Universes are
# created on a 2d grid. I would like it to be hexagonal movement type so that
# it feels less like a grid and more like a circle. This means that we will
# have some interesting point systems. For now I am thinking that we will have
# three directions, a, b, c which will corespond to moving along one of the
# lines to another system. Here is an ascii art:
#
#
# (0, 0, 1) (0, 1, 0)
# \ /
# \ /
# \ /
# (+c) (+b)
# \ /
# \ /
# \ /
# (-1, 0, 0) <--- (-a) --- (0, 0, 0) --- (+a) ---> (1, 0, 0)
# / \
# / \
# /
# (-b) (-c)
# / \
# / \
# / \
# (0, -1, 0) (0, 0, -1)
#
#
# This means that we are adding vector together. I do not think that this will
# work super well though with a system that is other than 2D. I think that will
# be fine.
#
# After thinking some more I will have the universe class hold everything in
# the universe. It will be in charge of holding all of the ships and
# planets. Then the Player class will be connected to the ships and planets
# that they own/control
#
# @param name [String] The name of the game
# @param universe_size [Integer] Controlls the size of the generated universe. This is a radius from the center point.
class Universe
# Setup logger for this class:
@@logger = Logger.new('logs/universe.log')
attr_reader :name
def initialize(name, universe_size: 4, create_chances: {planet: 1}, seed: nil)
@name = name
@universe_size = universe_size
@create_chances = create_chances
# Setup internal lists:
@player_list = []
@ship_list = []
# initizlize RNG
if seed.nil?
@random = Random.new()
@seed = @random.seed
else
@seed = seed
@random = Random.new(@seed)
end
end # Initialize
def create_universe()
# Set center of universe
@center = Position.new(@universe_size + 2, @universe_size)
@@logger.info "Generating Universe '#{@name}' with seed #{@seed}"
@@logger.info "Center is #{@center}"
@universe_map = UniverseArray.new()
# Generate the universe. First generate the x values:
(0 .. (@universe_size * 2)).each do |y_pos|
@universe_map[y_pos] = []
(0 .. (@universe_size * 2 + 2)).each do |x_pos|
# Make the grid work right, x is actually skipping every other.
x_pos *= 2
x_pos += 1 if y_pos.odd?
position = Position.new(x_pos, y_pos)
# Skip if the node is too far
if distance(@center, position) >= @universe_size
next
end
# Skip if it is the center
if position == @center
end
# Get what the planet should be:
spot = nil
if @random.rand < @create_chances[:planet]
spot = Planet.new(@random)
end
@@logger.debug "Creating something at (#{position})"
@universe_map[position] = spot
end # Create y points
end # Create x points
# Remove any empty nodes:
end # Function create_universe
# Returns the distance between points. This is returned as a number which
# indicates how far you have to go to get to a point. This is actually
# supurisingly hard...
#
# @param position_a [Position] A start point in the universe
# @param position_b [Position] Another point in the universe
# @return [Integer] The distance between points in the universe.
def distance(position_a, position_b)
result = 0 # We can just add the abs diff of each direction.
require 'byebug';debugger if position_a.nil?
dist_x = (position_b.x_pos - position_a.x_pos).abs
dist_y = (position_b.y_pos - position_a.y_pos).abs
res = (dist_x / 2) + dist_y
if dist_x >= dist_y
res = (dist_x + dist_y) / 2
end
@@logger.debug "Distance: #{position_a} and #{position_b} are #{res} units away"
return res
end # Function distance
# Prints the universe to the screen.
def print_universe(simple: false)
(0 .. (@universe_map.length)).each do |y_pos|
# Print empty space before chart (for angles)
if @universe_map[y_pos].nil?
next
end
if @universe_map[y_pos] == []
puts '_' * (4 * @universe_size * 2)
next
end
nextline = '|'
if y_pos < @universe_size
nextline += ' ' * ((@universe_size - y_pos).abs - 1)
else
nextline += ' ' * ((@universe_size - y_pos).abs)
end
if @universe_size.odd?
nextline += ' '
end
# Print more empty space for the odd places (provides offset)
if y_pos.odd?
print '| '
else
print '|'
end
#Actually loop through everything
(0 .. (@universe_size * 2)).each do |x_pos|
# Make the grid work right, x is actually skipping every other.
x_pos *= 2
x_pos += 1 if y_pos.odd?
position = Position.new(x_pos, y_pos)
# Print empty space if we are outside the universe (For items)
if distance(@center, position) >= @universe_size
print ' '
next
end
# Print what we are:
value = @universe_map[position]
nextline += ' '
if position == @center
print 'O'
elsif value.class == Planet
print 'P'
else
print ' '
end
# Calculate what we should print on the next line and if we should print dashes
if y_pos < @universe_size
nextline += '/ \\'
end
if distance(@center, Position.new(x_pos + 2, y_pos)) < @universe_size
print '---'
if y_pos >= @universe_size
nextline += '\\ /'
end
end
end # For each col in row
puts ''
if (y_pos + 1) / 2 < @universe_size && !simple
puts nextline
end
end # For each row
end # Function print_universe
# Returns all of the planets contained in the universe:
#
# @return [Array<Planet>]
def planets
result = []
@universe_map.flatten.each do |item|
result.push(item) if item.is_a? Planet
end
return result
end
################################################################################
################################################################################
################################################################################
################################################################################
################################################################################
################################################################################
##################################################
################### UNIVERSE #####################
##################################################
# This is where API stuff for the universe should go
# Gets a position from the universe for a player. Eventually this will take
# into account that the player can not actually see the whole universe
#
# @param player [Player] The player who is asking for the position
# @param position [Position] The position that is being requested
# @return [Planet, nil] Returns the thing at that point.
def get_position(player, position)
return @universe_map[position]
end
# Sets a position as explored for a player. This should be called as ships
# move around and explore the universe.
#
# @param player [Player] The player who should be set
# @param position [Position] The position that is being modified
def explore_position(player, position)
end
##################################################
##################### SHIPS ######################
##################################################
# Create a ship in the universe. This ship is registered to a specific player
#
# @param player [Player] The player who owns the ship
# @param position [Position] The position that the ship should be created at
def create_ship(player, position)
end
# Move a player's ship to a given position
#
# @param player [Player] The player whose ship should be moved
# @param ship [Ship] The ship that should be moved
# @param position [Position] The position the ship should be moved to.
def move_ship(player, ship, position)
end
##################################################
##################### PLANETS ####################
##################################################
# As all planets are created when the galaxy is generated there are no create
# planet functions. It is possible that we want to remove them though
# Remove a planet from the universe
#
# @param planet [Planet] The planet that should be removed
# @param position [Position] The position that it should be removed from
def remove_planet(planet, position)
end
# Change the ownership of a planet
#
# @param planet [Planet] The planet to be modified
# @param player [Player] The player who should now own the planet
# @param position [Position] The position of the planet
##################################################
##################### PLAYER #####################
##################################################
# Add a player to the player list in the universe
#
# @param player [Player] The player who should be added to the universe
def add_player(player)
@player_list.push(player)
# Give the player a planet
free_planets = []
planets.each do |planet|
free_planets.push(planet) if planet.owner.nil?
end
return 'Added player to universe'
end
# Remove a player from the list in this universe
#
# @param player [Player] The player that should be removed from the player list.
def remove_player(player)
if @player_list.delete(player).nil?
return 'Failed to remove player! Does not exist in this universe!'
end
return 'Removed player from universe'
end
# Returns a list of ships owned by the player
#
# @param player [Player] The player who wants to get their ships.
def get_player_ships(player)
result = []
@ship_list.each do |ship|
if ship.owner == player
result.push(ship)
end
end
return result
end # Function get_player_ships
# Returns a list of planets owned by the player
#
# @param player [Player] The player who wants to get their planets.
def get_player_planets(player)
result = []
@planet_list.each do |planet|
if planet.owner == player
result.push(planet)
end
end
return result
end
end # Class Universe
-27
View File
@@ -1,27 +0,0 @@
# Nathan Hinton 1 Oct 2025
# Create a custom hash class for the universe.
require './position'
# This will contain the universe hash inside it. It will allow for array type
# indexing as well as point type indexing
class UniverseArray < Array
def initialize
end
def [](index, *rest)
if index.is_a?(Position)
return self[index.y_pos][index.x_pos]
else
super
end
end
def []=(index, *rest)
if index.is_a?(Position)
return self[index.y_pos][index.x_pos] = rest[0]
else
super
end
end
end
+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
+55
View File
@@ -0,0 +1,55 @@
# Main entry point for the space game server.
# This script initializes the game engine and starts a multithreaded TCP server.
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 = ''
response = game.parse_command(client, encoded_command)
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
+157
View File
@@ -0,0 +1,157 @@
# Defines a building that can be constructed on a planet.
# Handles resource production and build power generation.
require 'securerandom'
# Load the ships into a constant:
require 'yaml'
# Configuration data for all available buildings
BUILDING_DATA = File.open('lib/game/data/buildings.yaml', 'r') do |fi|
YAML.safe_load(fi.read())
end
# Represents a building constructed on a planet. Provides resources or build power.
class Building
attr_reader :name
# Create a building. Requires a player and a planet to construct on.
#
# @param player [Player] The player who owns the building.
# @param planet [Planet] The planet the building should be built on.
# @param name [String] The name of the building to be built.
def initialize(player, planet, name)
@player = player
@planet = planet
@name = name
@building_exist_time = 0
@building_last_production_time = 0
# Return error if the building does not exist
@building = BUILDING_DATA[name]
if @building.nil?
raise BuildingError, "Invalid building name \`#{name}\`"
end
# Check the player owns the planet
if planet.owner != player
raise BuildingError, 'Player does not own planet!'
end
# Check the player has the resources
resources = ['metal', 'crystals', 'fuel', 'credits']
resources.each do |resource|
if @player.player_resources[resource] < @building['build_cost'][resource]
raise BuildingError, "Player does not have enough #{resource}!"
end
end
resources.each do |resource|
@player.player_resources[resource] -= @building['build_cost'][resource]
end
# Check that the player owns the needed buildings to construct:
player_buildings = @player.all_buildings.map{|building| building.name}
@building['required_buildings'].each do |required_building|
unless player_buildings.include? required_building
raise BuildingError, "Requires \`#{required_building}\` first"
end
end
# Check that the player owns the needed tech to construct:
player_tech = @player.all_tech.map{|tech| tech.name}
@building['required_tech'].each do |required_tech|
unless player_tech.include? required_tech
raise BuildingError, "Requires the tech of \`#{required_tech}\` first"
end
end
end
# This is in charge of doing things like adding to the turn build power and
# providing resources from buildings.
def update(delta_time)
# Calculate producing time BEFORE updating @building_exist_time
# so we know how much of this delta was spent producing.
time_before = @building_exist_time
@building_exist_time += delta_time
# Do not produce resources if the building is not finished
if @building_exist_time < @building['build_cost']['time']
return # Exit
end
# Producing time is the part of delta_time that happened after the building was finished.
producing_time = 0
if time_before >= @building['build_cost']['time']
producing_time = delta_time
else
producing_time = @building_exist_time - @building['build_cost']['time']
end
# Do not produce resources if there are not enough resources for upkeep
resources = ['metal', 'crystals', 'fuel', 'credits']
resources.each do |resource|
if @player.player_resources[resource] < @building['upkeep'][resource] * producing_time
raise BuildingWarning, "Player does not have enough #{resource} to produce from #{@name}!"
end
end
resources.each do |resource|
@player.player_resources[resource] -= @building['upkeep'][resource] * producing_time
end
# Production time only increases for the producing part
@building_last_production_time += producing_time
# If the building is a resource production building we need to add the resources:
if @building['classification'] == 'mining'
if @building_last_production_time >= 1
num_productions = (@building_last_production_time / 1).floor
@building_last_production_time -= num_productions
resources.each do |resource|
@player.player_resources[resource] += @building['produces'][resource] * num_productions
end
end
elsif @building['classification'] == 'production'
# This is added per tick. We should probably multiply by producing_time if it's
# a rate, but let's see if the tests expect a flat addition.
# In the specs: building.update(1.0) -> expecting 100 build power.
# If producing_time is 1.0, we add once.
if producing_time > 0
@player.ship_build_power += @building['produces']['ship_build_power']
@player.research_tech_build_power += @building['produces']['research_tech_build_power']
@player.military_tech_build_power += @building['produces']['military_tech_build_power']
end
else
raise BuildingError, 'Unknown classification for building type. Trying to add resources etc...'
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 the buildings
# Represents a building constructed on a planet. Provides resources or build power.
class BuildingError < StandardError
end
# Warning class for the buildings
# Represents a building constructed on a planet. Provides resources or build power.
class BuildingWarning < StandardError
end
+176
View File
@@ -0,0 +1,176 @@
# Parser for client commands.
# Translates incoming JSON requests into game actions.
# Handles the parsing of incoming JSON commands from clients.
class CommandParser
def initialize
end
# Parses a command based on its type.
# @param client [TCPSocket] The client socket.
# @param command [Hash] The command hash.
# @return [Hash] The response hash.
def parse(client, command)
response = {'request_id' => command['request_id']}
case command['type']
when 'hello'
parse_hello(client, command)
when 'query'
parse_query(command)
when 'command'
parse_command(command)
else
raise CommandParserError, "Unable to parse unknown type of '#{commad['type']}'"
end
return response
end
# Parses a "hello" type command.
# @param client [TCPSocket] The client socket.
# @param command [Hash] The command hash.
# @return [Hash] The response hash.
def parse_hello(client, command)
response = {'type' => command['type']}
id = command['player_id']
case command['domain']
when 'player'
hello_player(client, id, command['payload'])
else
raise CommandParserError, "Unable to parse the hello type, domain '#{command['domain']}'"
end
return response
end
# Parses a "query" type command.
# @param command [Hash] The command hash.
# @return [Hash] The response hash.
def parse_query(command)
response = {'type' => command['type']}
id = command['player_id']
case command['domain']
when 'game'
query_game(id, command['payload'])
when 'player'
query_player(id, command['payload'])
else
raise CommandParserError, "Unable to parse the query type, domain '#{command['domain']}'"
end
return response
end
# Parses a "command" type command.
# @param command [Hash] The command hash.
# @return [Hash] The response hash.
def parse_command(command)
response = {'type' => command['type']}
id = command['player_id']
case command['domain']
when 'game'
command_game(id, command['payload'])
when 'player'
command_player(id, command['payload'])
else
raise CommandParserError, "Unable to parse the command type, domain '#{command['domain']}'"
end
return response
end
# Perform the actions for the type of hello and domain of player.
def hello_player(client, id, payload)
response = {}
payload.keys.each do |key, value|
case key
when 'build'
when 'connect'
game.connect_player(client, id, value)
when 'faction'
game.set_player_faction(id, value)
else
raise CommandParserError, "Unable to parse the type hello, domain player, key '#{key}'"
end
end
return response
end
# Perform the actions for the type of query and the domain of game
def query_game(id, payload)
response = {}
payload.keys.each do |key, value|
case key
when 'build'
when 'time'
game.send_time(id, value)
when 'size'
game.send_size(id, value)
else
raise CommandParserError, "Unable to parse the type query, domain game, key '#{key}'"
end
end
return response
end
# Perform the actions for the type of query and the domain of player
def query_player(id, payload)
response = {}
payload.keys.each do |key, value|
case key
when 'build'
when 'resources'
game.get_player_resources(id, value)
when 'planets'
game.get_player_planets(id, value)
when 'buildings'
game.get_player_buildings(id, value)
when 'ships'
game.get_player_ships(id, value)
when 'construction'
game.get_player_construction(id, value)
else
raise CommandParserError, "Unable to parse the type query, domain player, key '#{key}'"
end
end
return response
end
# Perform the actions for the type command and the domain of game
def command_game(id, payload)
response = {}
payload.keys.each do |key, value|
case key
when 'build'
else
raise CommandParserError, "Unable to parse the type command, domain game, key '#{key}'"
end
end
return response
end
# Perform the actions for the type command and the domain of player
def command_player(id, payload)
response = {}
payload.keys.each do |key, value|
case key
when 'build'
else
raise CommandParserError, "Unable to parse the type command, domain player, key '#{key}'"
end
end
return response
end
end
# Handles the parsing of incoming JSON commands from clients.
# Exception raised when a command cannot be parsed.
class CommandParserError < StandardError
end
+328
View File
@@ -0,0 +1,328 @@
# Nathan Hinton 2 Jan 2025 Buildings for the game
#
# Production buildings:
Shipyard Level 1:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 1,000
crystals: 1,000
fuel: 1,000
credits: 1,000
time: 1,000
upkeep:
metal: 100
crystals: 100
fuel: 100
credits: 100
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 100
research_tech_build_power: 0
military_tech_build_power: 0
required_tech: []
required_buildings: []
Shipyard Level 2:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 2,000
crystals: 2,000
fuel: 2,000
credits: 2,000
time: 2,000
upkeep:
metal: 200
crystals: 200
fuel: 200
credits: 200
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 200
research_tech_build_power: 0
military_tech_build_power: 0
required_tech: []
required_buildings: [Shipyard Level 1]
Space Construction Station Level 1:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 2,000
crystals: 2,000
fuel: 2,000
credits: 2,000
time: 2,000
upkeep:
metal: 200
crystals: 200
fuel: 200
credits: 200
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 200
research_tech_build_power: 0
military_tech_build_power: 0
required_tech: [Anti Gravity Thrusters 1]
required_buildings: [Shipyard Level 1]
Space Construction Station Level 2:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 3,000
crystals: 3,000
fuel: 3,000
credits: 3,000
time: 3,000
upkeep:
metal: 300
crystals: 300
fuel: 300
credits: 300
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 300
research_tech_build_power: 0
military_tech_build_power: 0
required_tech: [Anti Gravity Thrusters 2]
required_buildings: [Space Construction Station Level 1, Shipyard Level 2]
Research Factory Level 1:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 1,000
crystals: 1,000
fuel: 1,000
credits: 1,000
time: 1,000
upkeep:
metal: 100
crystals: 100
fuel: 100
credits: 100
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 0
research_tech_build_power: 100
military_tech_build_power: 0
required_tech: []
required_buildings: []
Research Factory Level 2:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 2,000
crystals: 2,000
fuel: 2,000
credits: 2,000
time: 2,000
upkeep:
metal: 200
crystals: 200
fuel: 200
credits: 200
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 0
research_tech_build_power: 200
military_tech_build_power: 0
required_tech: []
required_buildings: [Research Factory Level 1]
Military Factory Level 1:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 2,000
crystals: 2,000
fuel: 2,000
credits: 2,000
time: 2,000
upkeep:
metal: 200
crystals: 200
fuel: 200
credits: 200
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 0
research_tech_build_power: 0
military_tech_build_power: 100
required_tech: []
required_buildings: [Research Factory Level 1]
Military Factory Level 2:
classification: production
faction: good
build_cost: # This is what is required to produce the building:
metal: 3,000
crystals: 3,000
fuel: 3,000
credits: 3,000
time: 3,000
upkeep:
metal: 300
crystals: 300
fuel: 300
credits: 300
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
ship_build_power: 0
research_tech_build_power: 0
military_tech_build_power: 200
required_tech: []
required_buildings: [Military Factory Level 1]
# Harvesting
# Each building will produce the primary resource at %60, a secondary resource
# at %24, a terciary resource at %10 and a final resource at %6. Here is the
# rotation of how they are related:
# metal -> crystal -> fuel -> credits
Metal Mines 1:
classification: mining
faction: good
build_cost: # This is what is required to produce the building:
metal: 1,000
crystals: 1,000
fuel: 1,000
credits: 1,000
time: 1,000
upkeep:
metal: 100
crystals: 100
fuel: 100
credits: 100
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
metal: 600
crystals: 240
fuel: 100
credits: 60
required_tech: []
required_buildings: []
Crystal Collector 1:
classification: mining
faction: good
build_cost: # This is what is required to produce the building:
metal: 1,000
crystals: 1,000
fuel: 1,000
credits: 1,000
time: 1,000
upkeep:
metal: 100
crystals: 100
fuel: 100
credits: 100
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
metal: 60
crystals: 600
fuel: 240
credits: 100
required_tech: []
required_buildings: []
Fuel Refinery 1:
classification: mining
faction: good
build_cost: # This is what is required to produce the building:
metal: 1,000
crystals: 1,000
fuel: 1,000
credits: 1,000
time: 1,000
upkeep:
metal: 100
crystals: 100
fuel: 100
credits: 100
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
metal: 100
crystals: 60
fuel: 600
credits: 240
required_tech: []
required_buildings: []
Credit Center 1:
classification: mining
faction: good
build_cost: # This is what is required to produce the building:
metal: 1,000
crystals: 1,000
fuel: 1,000
credits: 1,000
time: 1,000
upkeep:
metal: 100
crystals: 100
fuel: 100
credits: 100
base_stats:
hp: 1,000
shield: 1,000
armor: 30
def: 30
produces:
metal: 240
crystals: 100
fuel: 60
credits: 600
required_tech: []
required_buildings: []
+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: []
+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: []
+417
View File
@@ -0,0 +1,417 @@
# frozen_string_literal: true
# Space game server engine.
# Coordinates game state, player connections, and world simulation.
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
# @option command [String] :faction Required, The faction the player should join as.
#
# @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.set_resources(
@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 client [TCPSocket] The socket that the client used.
# @param command [Hash] The parsed client command
#
# @return [String] JSON-encoded response
#
def parse_command(client, command)
return error_response('Player not registered!') unless command['player_id']
case command['type']
when 'hello'
parse_hello(client, command)
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', 'request_id' => -1, 'payload' => msg }.to_json
end
##
# Return an update response to the client
def update_response(request_id, domain, payload)
{'type' => 'update', 'domain' => domain, 'request_id' => request_id, 'payload' => payload}.to_json
end
# Return an event response to the client
def event_response(request_id, domain, payload)
{'type' => 'event', 'domain' => domain, 'request_id' => request_id, 'payload' => payload}.to_json
end
##
# 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
# Order here is important, We must update the buildings first (To
# accrew resources for this delta) Because planets hold the buildings
# they must be first.
@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
# Loads players from a list of IDs.
# @param ids [Array<String>] list of player IDs.
# @return [Array<Player>] the loaded players.
def load_players(ids)
ids.map { |id| Player.load_from_id(id) }
end
# Loads planets from a configuration list or creates new ones.
# @param planets [Integer, Array<String>] number of planets to create or list of planet IDs.
# @return [Array<Planet>] the loaded or created planets.
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
# Loads ships from a list of IDs.
# @param ids [Array<String>] list of ship IDs.
# @return [Array<Ship>] the loaded ships.
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
+93
View File
@@ -0,0 +1,93 @@
# Represents a celestial body in the game that can be owned by a player.
# It tracks its position and can hold various buildings.
require 'securerandom'
# A planet object in the game that can be owned by a player and contains buildings.
#
# @example
# planet = Planet.new(100, 100, 100)
#
class Planet
# The owner of the planet, if any.
attr_reader :owner
# The unique identifier for the planet.
attr_reader :planet_id
# Creates a new planet with random position in the game space.
#
# @param width [Integer] The width of the game space.
# @param height [Integer] The height of the game space.
# @param depth [Integer] The depth of the game space.
# @return [Planet] The new planet instance.
#
def initialize(width, height, depth)
@owner = nil
@buildings = []
@position = [rand(width), rand(height), rand(depth)]
@planet_id = SecureRandom.hex
end
# Determines if the planet can be claimed by a player.
# A planet is claimable if it has no owner or no buildings.
#
# @return [Boolean] true if the planet can be claimed, false otherwise.
#
def claimable?
if @owner.nil?
return true
elsif @buildings == []
return true
end
return false
end
# Claims the planet and sets the player as the owner.
#
# @param player [Player] The player claiming the planet.
#
def claim(player)
@owner = player
end
# Called to update the planet during the game tick.
#
# @param delta_time [Float] The time elapsed since the last update.
#
def update(delta_time)
end
# Returns information about the planet to be sent to the client.
#
# @return [Hash] A hash containing the planet's ID and position.
#
def info_update
{
'id' => @planet_id,
'position' => @position
}
end
# Saves the planet to a file for persistence.
#
# @return [String] The planet ID that was saved.
#
def save_to_file
File.open("game_data/#{self.planet_id}_planet_.save", 'wb') do |fi|
fi.write(Marshal.dump(self))
end
self.planet_id
end
# Loads a planet from a save file.
#
# @param fname [String] The filename of the save file.
# @return [Planet] The loaded planet instance.
#
def self.load_from_save(fname)
File.open("game_data/#{fname}_planet_.save", 'rb') do |fi|
Marshal.load(fi.read)
end
end
end
+180
View File
@@ -0,0 +1,180 @@
# Represents a player in the game.
# Tracks resources, owned planets, ships, and researched technology.
require 'securerandom'
require 'yaml'
# A player object for the class Game
class Player
attr_accessor :player_resources, :player_id, :player_planets, :player_ships, :faction, :researched_tech, :buildings, :ship_build_power, :research_tech_build_power, :military_tech_build_power
# Create a new player. Default values are stored in the game_config.yaml file.
# @param player_id [String] An ID used to validate the player.
# @param faction [String] The faction that the player should start as. Should be \`good\` or \`evil\`
def initialize(player_id, faction)
@player_id = player_id
@player_planets = []
@player_ships = []
@home_planet = nil
@researched_tech = []
@buildings = []
@ship_build_power = 0
@research_tech_build_power = 0
@military_tech_build_power = 0
# Validate faction:
if !(['good', 'evil'].include? faction)
raise PlayerError, 'Faction must be \`good\` or \`evil\`'
end
@faction = faction
end
def set_resources(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,
researched_tech: @researched_tech,
buildings: @buildings,
ship_build_power: @ship_build_power,
research_tech_build_power: @research_tech_build_power,
military_tech_build_power: @military_tech_build_power
}
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]
@researched_tech = data[:researched_tech] || []
@buildings = data[:buildings] || []
@ship_build_power = data[:ship_build_power] || 0
@research_tech_build_power = data[:research_tech_build_power] || 0
@military_tech_build_power = data[:military_tech_build_power] || 0
end
# Returns all buildings owned by the player.
# @return [Array<Building>] list of buildings.
def all_buildings
@buildings
end
# Returns all researched technology for the player.
# @return [Array<String>] list of researched tech.
def all_tech
@researched_tech
end
end
# Errors for the player
class PlayerError < StandardError
end
+150
View File
@@ -0,0 +1,150 @@
# Represents a space-faring vessel owned by a player.
# Handles movement and validation of ship creation.
require 'securerandom'
# Load the ships into a constant:
require 'yaml'
# Configuration data for all available ships
SHIP_DATA = File.open('lib/game/data/ships.yaml', 'r') do |fi|
YAML.safe_load(fi.read())
end
# Represents a space-faring vessel owned by a player.
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
return ship
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
# Represents a space-faring vessel owned by a player.
class ShipError < StandardError
end
+87
View File
@@ -0,0 +1,87 @@
# Nathan Hinton 11 Jan 2026
# Test file for the main game. This is testing that the client/server responses
# are performed correctly.
#
# Here is how the tests are structured: There are several fields that need to
# be filled in a request to the server. These fields are: type, domain,
# request_id, payload, and player_id. In these tests the requrest_id will be
# randomized and checked that the response matches to that id. The client may
# use these id's however they wish. If there is an id passed in with a request,
# the matching response will have the same id.
#
# The first describe level is the 'type' field.
# The second describe level is the 'domain' field.
# The third describe level is the 'payload' field.
#
# These tests are not intended to show *every* possible configuration and
# combination of commands. They are simplified commands that will test the
# basic workings of the server. They should be used as a guide for constructing
# commands sent to the server though and are helpful in defining the interface
# that we will use.
#
# (Applicable to the second half of the file)
# Note that the update, event and error types are meant to come *only* from
# the server and not from the client. This means that those responses are
# tested using other commands to create them. These are more 'behavioral'
# tests than just demonstrations of how to construct requests and the
# possible combinations of options.
#
#
#
require 'socket'
require 'json'
HOSTNAME = 'localhost'
PORT = 2000
server_pid = spawn('rake serve', out: File::NULL, err: File::NULL)
sleep(0.5)
RSpec.describe "app.rb" do
let(:client) {TCPSocket.open(HOSTNAME, PORT)}
it 'responds with an error when a non JSON request is sent' do
client.puts 'this is non json test data'
expect(JSON.parse(client.gets)).to eq({'type' => 'error', 'domain' => 'error', 'payload' => 'Server unable to decode JSON'})
end
describe 'type: hello' do
describe 'domain: player' do
describe 'payload: player_id, faction' do
end
end
end
describe 'type: query' do
end
describe 'type: command' do
end
# Note that the update, event and error types are meant to come *only* from
# the server and not from the client. This means that those responses are
# tested using other commands to create them. These are more 'behavioral'
# tests than just demonstrations of how to construct requests and the
# possible combinations of options.
describe 'type: update' do
end
describe 'type: event' do
end
describe 'type: error' do
it 'returns an error when a player does not exist' do
client.puts({'type' => 'hello', 'domain' => 'player', 'payload' => {'player_id' => 'test_esfe_player', 'faction' => 'good'}}.to_json)
expect(JSON.parse(client.gets)).to eq({"payload" => "Player not registered!",
"request_id" => -1,
"type" => "error",}
)
end
end
end
Process.kill('TERM', server_pid)
Process.wait(server_pid)
+252
View File
@@ -0,0 +1,252 @@
# Nathan Hinton Jan 8 2026
# Test file for the buildings in the game
require 'game/building'
RSpec.describe Building do
let(:player) {instance_double(
'Player',
ship_build_power: 0,
research_tech_build_power: 0,
military_tech_build_power: 0,
player_resources: {
'metal' => 20_000,
'crystals' => 20_000,
'fuel' => 20_000,
'credits' => 20_000},
all_buildings: [],
all_tech: []
)}
let(:planet) {instance_double(
'Planet',
owner: player
)}
let(:anti_gravity_thrusters_1) {instance_double('Tech',
name: 'Anti Gravity Thrusters 1'
)}
let(:anti_gravity_thrusters_2) {instance_double('Tech',
name: 'Anti Gravity Thrusters 2'
)}
before(:all) do
end
describe '#initialize & #validate_building' do
it 'Can construct any of level one mining buildings' do
expect{Building.new(player, planet, 'Metal Mines 1')}.to_not raise_error
end
it 'Can construct basic factory buildings' do
expect{Building.new(player, planet, 'Shipyard Level 1')}.to_not raise_error
end
it 'Can construct second level factory buildings' do
allow(player).to receive(:all_buildings).and_return([Building.new(player, planet, 'Shipyard Level 1')])
expect{Building.new(player, planet, 'Shipyard Level 2')}.to_not raise_error
end
it 'Can construct third level (dependency check) buildings' do
allow(player).to receive(:all_tech).and_return([anti_gravity_thrusters_1, anti_gravity_thrusters_2]) # Required tech for buildings
allow(player).to receive(:all_buildings).and_return([Building.new(player, planet, 'Shipyard Level 1')]) # Building the second level requires the first first :)
allow(player).to receive(:all_buildings).and_return([Building.new(player, planet, 'Shipyard Level 2'), Building.new(player, planet, 'Space Construction Station Level 1')])
expect{Building.new(player, planet, 'Space Construction Station Level 2')}.to_not raise_error
end
it 'Raises an error when trying to build a building that does not have the required buildings' do
expect{Building.new(player, planet, 'Shipyard Level 2')}.to raise_error(BuildingError, 'Requires `Shipyard Level 1` first')
end
it 'Raises an error when trying to build a building that does not have the required tech' do
allow(player).to receive(:all_buildings).and_return([Building.new(player, planet, 'Shipyard Level 1')]) # Building the second level requires the first first :)
expect{Building.new(player, planet, 'Space Construction Station Level 1')}.to raise_error(BuildingError, 'Requires the tech of `Anti Gravity Thrusters 1` first')
end
it 'Raises an error when the player does not own the planet' do
allow(planet).to receive(:owner).and_return(nil)
expect{Building.new(player, planet, 'Shipyard Level 1')}.to raise_error(BuildingError, 'Player does not own planet!')
end
it 'Raises an error if the player does not have the required credits' do
allow(player).to receive(:player_resources).and_return(
{
'metal' => 20_000,
'crystals' => 20_000,
'fuel' => 20_000,
'credits' => 0}
)
expect{Building.new(player, planet, 'Shipyard Level 1')}.to raise_error(BuildingError, 'Player does not have enough credits!')
end
it 'Raises an error if the player does not have enough metal' do
allow(player).to receive(:player_resources).and_return(
{
'metal' => 0,
'crystals' => 20_000,
'fuel' => 20_000,
'credits' => 20_000}
)
expect{Building.new(player, planet, 'Shipyard Level 1')}.to raise_error(BuildingError, 'Player does not have enough metal!')
end
it 'Raises an error if the player does not have enough fuel' do
allow(player).to receive(:player_resources).and_return(
{
'metal' => 20_000,
'crystals' => 20_000,
'fuel' => 0,
'credits' => 20_000}
)
expect{Building.new(player, planet, 'Shipyard Level 1')}.to raise_error(BuildingError, 'Player does not have enough fuel!')
end
it 'Raises an error if the player does not have enough crystals' do
allow(player).to receive(:player_resources).and_return(
{
'metal' => 20_000,
'crystals' => 0,
'fuel' => 20_000,
'credits' => 20_000}
)
expect{Building.new(player, planet, 'Shipyard Level 1')}.to raise_error(BuildingError, 'Player does not have enough crystals!')
end
it 'Raises an error if the building does not exist' do
expect{Building.new(player, planet, 'billy bob joe')}.to raise_error(BuildingError, 'Invalid building name `billy bob joe`')
end
end
describe '#update/#produce_resources' do
before(:each) do
allow(player).to receive(:ship_build_power=)
allow(player).to receive(:research_tech_build_power=)
allow(player).to receive(:military_tech_build_power=)
end
describe 'Harvesting building produces resources' do
it 'Metal Mines' do
building = Building.new(player, planet, 'Metal Mines 1')
# Ensure the building is built:
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player.player_resources).to eq(
{
'metal' => 19_500,
'crystals' => 19_140,
'fuel' => 19_000,
'credits' => 18_960
})
end
it 'Crystal Collector' do
building = Building.new(player, planet, 'Crystal Collector 1')
# Ensure the building is built:
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player.player_resources).to eq(
{
'crystals' => 19_500,
'fuel' => 19_140,
'credits' => 19_000,
'metal' => 18_960
})
end
it 'Fuel Refinery' do
building = Building.new(player, planet, 'Fuel Refinery 1')
# Ensure the building is built:
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player.player_resources).to eq(
{
'fuel' => 19_500,
'credits' => 19_140,
'metal' => 19_000,
'crystals' => 18_960
})
end
it 'Credit Center' do
building = Building.new(player, planet, 'Credit Center 1')
# Ensure the building is built:
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player.player_resources).to eq(
{
'credits' => 19_500,
'metal' => 19_140,
'crystals' => 19_000,
'fuel' => 18_960
})
end
end
it 'Does not produce when there are not enough resources for upkeep' do
start_resources = {
'metal' => 1_010,
'crystals' => 1_020,
'fuel' => 1_030,
'credits' => 1_040}
allow(player).to receive(:player_resources).and_return(start_resources)
building = Building.new(player, planet, 'Credit Center 1')
building.instance_variable_set(:@building_exist_time, 10000)
expect{building.update(1.0)}.to raise_error(BuildingWarning, 'Player does not have enough metal to produce from Credit Center 1!')
expect(player.player_resources).to eq(start_resources)
end
it 'does not produce when it is not yet finished constructing' do
building = Building.new(player, planet, 'Metal Mines 1')
building.update(1.0)
expect(player.player_resources).to eq(
{
'metal' => 19_000,
'crystals' => 19_000,
'fuel' => 19_000,
'credits' => 19_000
})
end
it 'Having shipyard buildings allows the production of ships' do
building = Building.new(player, planet, 'Shipyard Level 1')
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player).to have_received(:ship_build_power=).with(100)
end
it 'Having research tech buildings allows the production of tech' do
building = Building.new(player, planet, 'Research Factory Level 1')
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player).to have_received(:research_tech_build_power=).with(100)
end
it 'Having military tech buildings allows the production of military tech' do
allow(player).to receive(:all_buildings).and_return([Building.new(player, planet, 'Research Factory Level 1')])
building = Building.new(player, planet, 'Military Factory Level 1')
building.instance_variable_set(:@building_exist_time, 10000)
building.update(1.0)
expect(player).to have_received(:military_tech_build_power=).with(100)
end
it 'Is in production for the right amount of time' do
building = Building.new(player, planet, 'Metal Mines 1')
building.update(1000.0)
expect(player.player_resources).to eq(
{
'metal' => 19_000,
'crystals' => 19_000,
'fuel' => 19_000,
'credits' => 19_000
})
building.update(1.0)
expect(player.player_resources).to eq(
{
'metal' => 19_500,
'crystals' => 19_140,
'fuel' => 19_000,
'credits' => 18_960
})
end
end
end
+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({"payload"=>"success", "type"=>"update"})
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({"payload"=>"success", "type"=>"update"})
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
+86
View File
@@ -0,0 +1,86 @@
# 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 '#info_update' do
it 'returns the correct data about the planet to the player' do
data = planet.info_update
expect(data.keys).to eq(['id', 'position'])
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
+285
View File
@@ -0,0 +1,285 @@
# Nathan Hinton 24 Dec 2025
# Test file for the player
# 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
# ------------------------------------------------------------------
# set_resources
# ------------------------------------------------------------------
describe '#set_resources' do
let(:credits) { 1000 }
let(:metal) { 500 }
let(:crystals) { 200 }
let(:fuel) { 300 }
it 'stores the resources hash correctly' do
subject.set_resources(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.set_resources(credits, metal, crystals, fuel)
subject.player_planets << double('planet', info_update: 'test_data')
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' => ['test_data'],
'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.set_resources(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.set_resources(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
+108
View File
@@ -0,0 +1,108 @@
# 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
end
describe '#validate_creation' do
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
@@ -13,12 +13,6 @@
# it.
#
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
if ENV['COVERAGE'] == 'true'
require 'simplecov'
SimpleCov.start
end
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest
@@ -50,6 +44,7 @@ RSpec.configure do |config|
# 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