Files
space_game_server/lib/game/game.rb
T

418 lines
13 KiB
Ruby

# 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