404 lines
12 KiB
Ruby
404 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Nathan Hinton, 22 Dec 2025
|
|
# Game server for the space game
|
|
|
|
require 'game/player'
|
|
require 'game/planet'
|
|
require 'thread'
|
|
require 'securerandom'
|
|
|
|
# Main file for the Game module
|
|
module Game
|
|
|
|
# Controls how frequently autosaves are created (seconds)
|
|
SAVE_INTERVAL = 15
|
|
|
|
# Controls how frequently updates are sent to the client about the player (seconds)
|
|
PLAYER_UPDATE_INTERVAL = 2
|
|
|
|
# Controls how fast the game scales with player count. Bigger means more
|
|
# players required to change the speed.
|
|
GAME_SPEED_FACTOR = 1
|
|
|
|
##
|
|
# The central server object that keeps track of players, planets, and
|
|
# game-time.
|
|
#
|
|
# @api public
|
|
class Game
|
|
# The name of the game
|
|
attr_reader :name
|
|
|
|
# Returns the width of the game space
|
|
attr_reader :width
|
|
|
|
# Returns the height of the game space
|
|
attr_reader :height
|
|
|
|
# Returns the depth of the game space
|
|
attr_reader :depth
|
|
|
|
# Collection of players in the game
|
|
attr_reader :players
|
|
|
|
# Create the game
|
|
##
|
|
# Initialize the game from a configuration hash. The hash is expected
|
|
# to be the result of parsing the YAML file `game_data/game_conf.yaml`.
|
|
#
|
|
# @param conf_data [Hash] The parsed configuration
|
|
# @raise [ArgumentError] if the configuration is missing a required key
|
|
#
|
|
def initialize(conf_data)
|
|
# Validate that the correct keys exist
|
|
['name', 'width', 'height', 'depth', 'players', 'planets', 'ships', 'time', 'player_defaults'].each do |key|
|
|
if conf_data[key].nil?
|
|
raise ArgumentError, "Missing toplevel key '#{key}' in configuration"
|
|
end
|
|
end
|
|
@conf_data = conf_data
|
|
@name = conf_data['name']
|
|
@width = conf_data['width']
|
|
@height = conf_data['height']
|
|
@depth = conf_data['depth']
|
|
@to_load_players = conf_data['players']
|
|
@to_load_planets = conf_data['planets']
|
|
@game_time = conf_data['time']
|
|
@player_defaults = @conf_data['player_defaults']
|
|
@connected_players = [] # When we start up we do not have any players connected.
|
|
|
|
# Load the players:
|
|
@players = []
|
|
@to_load_players.each do |player_id|
|
|
@players.append(Player.load_from_save(player_id))
|
|
end
|
|
|
|
# If we are an integer then we need to create new planets.
|
|
@planets = []
|
|
if @to_load_planets.is_a? Integer
|
|
puts "Appears to be the first time running. Creating #{@to_load_planets} planets"
|
|
@to_load_planets.times do
|
|
@planets.append(Planet.new(@width, @height, @depth))
|
|
end
|
|
else
|
|
@to_load_planets.length.times do |idx|
|
|
planet = Planet.load_from_save(@to_load_planets[idx])
|
|
@planets.append(planet)
|
|
end
|
|
end
|
|
|
|
# Load ships
|
|
@ships = []
|
|
conf_data['ships'].each do
|
|
require 'byebug'; debugger
|
|
end
|
|
end
|
|
|
|
##
|
|
# Register a client connection. This method sccepts a TCP docket and a hash
|
|
# that must comtain a `player_id`. If the player already exists in the game
|
|
# it is reconnected otherwise a new `Player` is created and added to the
|
|
# game if possible (There must be at least one free planet)
|
|
#
|
|
# @param client [TCPSocket] The client socket
|
|
# @param command [Hash] JSON parsed command from the client.
|
|
# @option command [String] :player_id Required, The player ID
|
|
# @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.create_new(
|
|
@player_defaults['credits'],
|
|
@player_defaults['metal'],
|
|
@player_defaults['crystals'],
|
|
@player_defaults['fuel'],
|
|
)
|
|
|
|
player.claim_planet(home)
|
|
player.make_home_planet(home)
|
|
player
|
|
end
|
|
|
|
##
|
|
# The entry point for all messages that arrive from a client. The
|
|
# payload must be a hash decoded from JSON and contain a `type` field.
|
|
#
|
|
# @param command [Hash] The parsed client command
|
|
#
|
|
# @return [String] JSON-encoded response
|
|
#
|
|
def parse_command(command)
|
|
return error_response('Player not registered!') unless command['player_id']
|
|
|
|
case command['type']
|
|
when 'dbg'
|
|
# Debugger - do nothing in production
|
|
{ 'type' => 'ok', 'payload' => 'debugging' }.to_json
|
|
when 'hello'
|
|
error_response('Hello command should already have been handled.')
|
|
when 'query'
|
|
parse_query(command)
|
|
when 'command'
|
|
error_response('Command handling not implemented')
|
|
else
|
|
error_response('Unknown type')
|
|
end
|
|
end
|
|
|
|
##
|
|
# Dispatch a `query` command to the appropriate helper. Only a small
|
|
# subset of query types is required for the test-suite; all others
|
|
# return a generic error.
|
|
#
|
|
# @param query [Hash] The query to be parsed
|
|
#
|
|
# @return [String] JSON-encoded response
|
|
#
|
|
def parse_query(query)
|
|
return error_response('Missing query payload') unless query['payload']
|
|
player = player_registered?(query['player_id'])
|
|
return error_response('Player not found') unless player
|
|
|
|
case query['domain']
|
|
when 'player'
|
|
case query['payload']
|
|
when 'update'
|
|
return player.update(query['payload']['request_id'])
|
|
else
|
|
error_response('Unknown payload for player domain')
|
|
end
|
|
else
|
|
error_response('Unknown domain')
|
|
end
|
|
end
|
|
|
|
##
|
|
# Return a status hash that contains a friendly error message. The
|
|
# helper exists only to keep the body of all error branches short.
|
|
#
|
|
# @param msg [String] The error message
|
|
# @return [String] JSON-encoded error response
|
|
#
|
|
def error_response(request_id, msg)
|
|
{ 'type' => 'error', 'request_id', => id, 'payload' => msg }.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
|
|
|
|
# --------------------------------------------------------------------
|
|
# PRIVATE HELPERS - kept simple for the test-suite
|
|
# --------------------------------------------------------------------
|
|
|
|
private
|
|
|
|
def error_response(msg)
|
|
{ 'type' => 'error', 'payload' => msg }.to_json
|
|
end
|
|
|
|
def load_players(ids)
|
|
ids.map { |id| Player.load_from_id(id) }
|
|
end
|
|
|
|
def load_planets(planets)
|
|
if planets.is_a?(Integer) # legacy config - integer means “create N”
|
|
Array.new(planets) { Planet.new }
|
|
else
|
|
planets.map { |id| Planet.load_from_id(id) }
|
|
end
|
|
end
|
|
|
|
def load_ships(ids)
|
|
ids.map { |id| Ship.load_from_id(id) } # Ship is a placeholder
|
|
end
|
|
end
|
|
|
|
##
|
|
# Raised when an invalid operation is attempted (e.g. reconnecting a
|
|
# player that already has a socket). The tests catch this error
|
|
# to confirm that the server protects against accidental double
|
|
# connections.
|
|
class GameError < StandardError; end
|
|
end
|