Compare commits
11 Commits
279658f3e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d2c2162f0 | |||
| df68358a2e | |||
| a1f31a89b2 | |||
| eb161e0f2e | |||
| 6e914467ed | |||
| 1ec24fbf12 | |||
| 645a47b98a | |||
| f4c5ba1f14 | |||
| 057164122a | |||
| 4737531805 | |||
| b8f54b44a3 |
@@ -4,3 +4,7 @@
|
||||
**/logs
|
||||
**/*tmp*
|
||||
**/coverage
|
||||
vendor/*
|
||||
Gemfile.lock
|
||||
.bundle/*
|
||||
game_data/*
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "async"
|
||||
gem "async-websocket"
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env ruby
|
||||
require_relative "../lib/app"
|
||||
|
||||
GameServer::App.start
|
||||
+104
@@ -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. |
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
@@ -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.**
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
# Nathan Hinton 2 Oct 2025
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -0,0 +1,40 @@
|
||||
# Nathan Hinton 31 Dec 2025
|
||||
# This holds a list of all modules in the game
|
||||
|
||||
# Here is how to create a module:
|
||||
#
|
||||
# name:
|
||||
# classification:
|
||||
# auxiluary/attack/defense # Pick one
|
||||
# faction:
|
||||
# good/evil
|
||||
# production:
|
||||
# metal/crystals/fuel
|
||||
# upkeep:
|
||||
# metal/crystals/fuel
|
||||
# add_stats: # These are additive to the base stat
|
||||
# stat_name: value
|
||||
# mult_stats: # These are multipulicative to the base stat (After adding the additive stats?)
|
||||
# stat_name: value # Greater than one will increase while less than one will decrease
|
||||
# required_tech: [] # The required tech to be researched *before* this one can be started
|
||||
# required_buildings: [] # The buildings required to begin this research
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
light shield:
|
||||
classification: defense
|
||||
faction: good
|
||||
production:
|
||||
metal: 1,000
|
||||
crystals: 1,000
|
||||
fuel: 0
|
||||
upkeep:
|
||||
metal: 10
|
||||
crustals: 10
|
||||
fuel: 0
|
||||
add_stats:
|
||||
shield: 1,000
|
||||
mult_stats: []
|
||||
required_tech: []
|
||||
required_buildings: []
|
||||
@@ -0,0 +1,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: []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 brand‑new instance and load the dumped data
|
||||
new_player = Player.new('dummy-id', 'good')
|
||||
new_player.marshal_load(dump)
|
||||
|
||||
expect(new_player.player_id).to eq(subject.player_id)
|
||||
expect(new_player.player_resources).to eq(subject.player_resources)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user