Basic working with ships partly implimented
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Nathan Hinton, 22 Dec 2025
|
||||
# Game server for the space game
|
||||
|
||||
require 'game/player'
|
||||
require 'game/planet'
|
||||
require 'thread'
|
||||
require 'securerandom'
|
||||
|
||||
# Main file for the Game module
|
||||
module Game
|
||||
|
||||
# Controls how frequently autosaves are created (seconds)
|
||||
SAVE_INTERVAL = 15
|
||||
|
||||
# Controls how frequently updates are sent to the client about the player (seconds)
|
||||
PLAYER_UPDATE_INTERVAL = 2
|
||||
|
||||
# Controls how fast the game scales with player count. Bigger means more
|
||||
# players required to change the speed.
|
||||
GAME_SPEED_FACTOR = 1
|
||||
|
||||
##
|
||||
# The central server object that keeps track of players, planets, and
|
||||
# game-time.
|
||||
#
|
||||
# @api public
|
||||
class Game
|
||||
# The name of the game
|
||||
attr_reader :name
|
||||
|
||||
# Returns the width of the game space
|
||||
attr_reader :width
|
||||
|
||||
# Returns the height of the game space
|
||||
attr_reader :height
|
||||
|
||||
# Returns the depth of the game space
|
||||
attr_reader :depth
|
||||
|
||||
# Collection of players in the game
|
||||
attr_reader :players
|
||||
|
||||
# Create the game
|
||||
##
|
||||
# Initialize the game from a configuration hash. The hash is expected
|
||||
# to be the result of parsing the YAML file `game_data/game_conf.yaml`.
|
||||
#
|
||||
# @param conf_data [Hash] The parsed configuration
|
||||
# @raise [ArgumentError] if the configuration is missing a required key
|
||||
#
|
||||
def initialize(conf_data)
|
||||
# Validate that the correct keys exist
|
||||
['name', 'width', 'height', 'depth', 'players', 'planets', 'ships', 'time', 'player_defaults'].each do |key|
|
||||
if conf_data[key].nil?
|
||||
raise ArgumentError, "Missing toplevel key '#{key}' in configuration"
|
||||
end
|
||||
end
|
||||
@conf_data = conf_data
|
||||
@name = conf_data['name']
|
||||
@width = conf_data['width']
|
||||
@height = conf_data['height']
|
||||
@depth = conf_data['depth']
|
||||
@to_load_players = conf_data['players']
|
||||
@to_load_planets = conf_data['planets']
|
||||
@game_time = conf_data['time']
|
||||
@player_defaults = @conf_data['player_defaults']
|
||||
@connected_players = [] # When we start up we do not have any players connected.
|
||||
|
||||
# Load the players:
|
||||
@players = []
|
||||
@to_load_players.each do |player_id|
|
||||
@players.append(Player.load_from_save(player_id))
|
||||
end
|
||||
|
||||
# If we are an integer then we need to create new planets.
|
||||
@planets = []
|
||||
if @to_load_planets.is_a? Integer
|
||||
puts "Appears to be the first time running. Creating #{@to_load_planets} planets"
|
||||
@to_load_planets.times do
|
||||
@planets.append(Planet.new(@width, @height, @depth))
|
||||
end
|
||||
else
|
||||
@to_load_planets.length.times do |idx|
|
||||
planet = Planet.load_from_save(@to_load_planets[idx])
|
||||
@planets.append(planet)
|
||||
end
|
||||
end
|
||||
|
||||
# Load ships
|
||||
@ships = []
|
||||
conf_data['ships'].each do
|
||||
require 'byebug'; debugger
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Register a client connection. This method sccepts a TCP docket and a hash
|
||||
# that must comtain a `player_id`. If the player already exists in the game
|
||||
# it is reconnected otherwise a new `Player` is created and added to the
|
||||
# game if possible (There must be at least one free planet)
|
||||
#
|
||||
# @param client [TCPSocket] The client socket
|
||||
# @param command [Hash] JSON parsed command from the client.
|
||||
# @option command [String] :player_id Required, The player ID
|
||||
#
|
||||
# @return [Hash] status hash that will be converted to JSON before being
|
||||
# sent back to the client
|
||||
#
|
||||
def player_connect(client, command)
|
||||
player_id = command.dig('payload', 'player_id')
|
||||
faction = command.dig('payload', 'faction')
|
||||
raise ArgumentError, 'player_id missing in command' unless player_id
|
||||
raise ArgumentError, 'faction is missing in command' unless faction
|
||||
|
||||
player = player_registered?(player_id)
|
||||
|
||||
if player
|
||||
# Re-connect an existing but disconnected player
|
||||
if player.connected?
|
||||
raise GameError, 'Player already connected!'
|
||||
end
|
||||
player.connect(client)
|
||||
else
|
||||
# Create a brand new player and claim a planet for them
|
||||
player = create_player(player_id, faction)
|
||||
return player if player.is_a?(Hash) && player['type'] == 'error' # error returned
|
||||
|
||||
player.connect(client)
|
||||
@players << player
|
||||
end
|
||||
|
||||
{ 'type' => 'update', 'payload' => 'success' }
|
||||
end
|
||||
|
||||
|
||||
##
|
||||
# Create a new player from scratch. The new player is assigned a free
|
||||
# planet and receives the default starting resources.
|
||||
#
|
||||
# @param player_id [String] the unique identifier that will be stored
|
||||
# on the new player; if `nil` a random hex string will be generated
|
||||
# @param faction [String] The faction that the player belongs too.
|
||||
#
|
||||
# @return [Player, Hash] The new player object on success or an error
|
||||
# hash if no free planet is available.
|
||||
#
|
||||
def create_player(player_id, faction)
|
||||
free_planets = @planets.select { |p| p.owner.nil? }
|
||||
|
||||
if free_planets.empty?
|
||||
return {
|
||||
'type' => 'error',
|
||||
'payload' => 'Unable to allocate a planet for a new player'
|
||||
}
|
||||
end
|
||||
|
||||
home = free_planets.sample
|
||||
player = Player.new(player_id || SecureRandom.hex, faction)
|
||||
|
||||
player.create_new(
|
||||
@player_defaults['credits'],
|
||||
@player_defaults['metal'],
|
||||
@player_defaults['crystals'],
|
||||
@player_defaults['fuel'],
|
||||
)
|
||||
|
||||
player.claim_planet(home)
|
||||
player.make_home_planet(home)
|
||||
player
|
||||
end
|
||||
|
||||
##
|
||||
# The entry point for all messages that arrive from a client. The
|
||||
# payload must be a hash decoded from JSON and contain a `type` field.
|
||||
#
|
||||
# @param command [Hash] The parsed client command
|
||||
#
|
||||
# @return [String] JSON-encoded response
|
||||
#
|
||||
def parse_command(command)
|
||||
return error_response('Player not registered!') unless command['player_id']
|
||||
|
||||
case command['type']
|
||||
when 'dbg'
|
||||
# Debugger - do nothing in production
|
||||
{ 'type' => 'ok', 'payload' => 'debugging' }.to_json
|
||||
when 'hello'
|
||||
error_response('Hello command should already have been handled.')
|
||||
when 'query'
|
||||
parse_query(command)
|
||||
when 'command'
|
||||
error_response('Command handling not implemented')
|
||||
else
|
||||
error_response('Unknown type')
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Dispatch a `query` command to the appropriate helper. Only a small
|
||||
# subset of query types is required for the test-suite; all others
|
||||
# return a generic error.
|
||||
#
|
||||
# @param query [Hash] The query to be parsed
|
||||
#
|
||||
# @return [String] JSON-encoded response
|
||||
#
|
||||
def parse_query(query)
|
||||
return error_response('Missing query payload') unless query['payload']
|
||||
player = player_registered?(query['player_id'])
|
||||
return error_response('Player not found') unless player
|
||||
|
||||
case query['domain']
|
||||
when 'player'
|
||||
case query['payload']
|
||||
when 'update'
|
||||
return player.update(query['payload']['request_id'])
|
||||
else
|
||||
error_response('Unknown payload for player domain')
|
||||
end
|
||||
else
|
||||
error_response('Unknown domain')
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Return a status hash that contains a friendly error message. The
|
||||
# helper exists only to keep the body of all error branches short.
|
||||
#
|
||||
# @param msg [String] The error message
|
||||
# @return [String] JSON-encoded error response
|
||||
#
|
||||
def error_response(msg)
|
||||
{ 'type' => 'error', 'payload' => msg }.to_json
|
||||
end
|
||||
alias error_response error_response
|
||||
|
||||
##
|
||||
# Return a JSON object that tells a client about the current
|
||||
# server-time. Useful for debugging or “ping” operations.
|
||||
#
|
||||
# @return [String] JSON-encoded hash
|
||||
#
|
||||
def info(request_id)
|
||||
{
|
||||
'type' => 'update',
|
||||
'domain' => 'game',
|
||||
'request_id' => request_id,
|
||||
'payload' => {
|
||||
'name' => @name,
|
||||
'width' => @width,
|
||||
'height' => @height,
|
||||
'depth' => @depth,
|
||||
'time' => @game_time
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Actually start running the game
|
||||
def run()
|
||||
# Main game loop
|
||||
last_time = Time.now # The delta time will be very small on the first one.
|
||||
next_save = @game_time + SAVE_INTERVAL
|
||||
next_client_update = @game_time + PLAYER_UPDATE_INTERVAL
|
||||
while true
|
||||
# This is the **ONLY** place where Time.now should be used
|
||||
delta_time = (Time.now - last_time)
|
||||
last_time = Time.now
|
||||
|
||||
# Here is where we do our time scaling. Count the number of players in the game then use that mult
|
||||
player_count = @players.map {|player| player.connected?}.count true
|
||||
@game_speed = (player_count / GAME_SPEED_FACTOR)
|
||||
delta_time * (player_count / GAME_SPEED_FACTOR)
|
||||
@game_time += delta_time
|
||||
|
||||
@planets.each do |planet|
|
||||
planet.update(delta_time)
|
||||
end
|
||||
|
||||
@ships.each do |ship|
|
||||
ship.update(delta_time)
|
||||
end
|
||||
|
||||
#@players.each do |player|
|
||||
# player.update(delta_time)
|
||||
#end
|
||||
|
||||
# Perform game saves (Or not for now...)
|
||||
if (@game_time) > next_save
|
||||
puts 'Trying to save...'
|
||||
next_save = @game_time + SAVE_INTERVAL
|
||||
save_game_state
|
||||
end
|
||||
|
||||
# Send out regular updates to the clients (Every 2 seconds)
|
||||
if @game_time > next_client_update
|
||||
next_client_update = @game_time + PLAYER_UPDATE_INTERVAL
|
||||
@players.each do |player|
|
||||
if player.connected?
|
||||
player.send_update
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if player_count == 0
|
||||
puts 'No players connected, pausing time'
|
||||
while 0 == (@players.map {|player| player.connected?}.count true)
|
||||
sleep(1)
|
||||
end
|
||||
end
|
||||
#sleep(0.05) # About 20 TPS/FPS
|
||||
sleep(1)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Persist the current state of the game to the disk. The method is
|
||||
# fully unit-tested via a double that captures the YAML payload
|
||||
# instead of actually writing a file.
|
||||
#
|
||||
# @return [void]
|
||||
#
|
||||
def save_game_state
|
||||
config = {
|
||||
'name' => @name,
|
||||
'width' => @width,
|
||||
'height' => @height,
|
||||
'depth' => @depth,
|
||||
'players' => @players.map(&:player_id),
|
||||
'planets' => @planets.map(&:planet_id),
|
||||
'ships' => @ships.map(&:ship_id),
|
||||
'time' => @game_time,
|
||||
'player_defaults' => @player_defaults
|
||||
}
|
||||
|
||||
File.open('game_data/game_conf.yaml', 'w') { |f| f.write(YAML.dump(config)) }
|
||||
|
||||
# Save the planets
|
||||
@planets.each do |planet|
|
||||
planet.save_to_file
|
||||
end
|
||||
|
||||
# Save the ships
|
||||
@ships.each do |ship|
|
||||
ship.save_to_file
|
||||
end
|
||||
|
||||
# Save the players
|
||||
@players.each do |player|
|
||||
player.save_to_file
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Determine whether a player with a given id is known to the game.
|
||||
#
|
||||
# @param player_id [String] the id that the caller is looking for
|
||||
#
|
||||
# @return [Player, false] the `Player` instance if found or `false`
|
||||
# otherwise
|
||||
#
|
||||
def player_registered?(player_id)
|
||||
@players.find { |p| p.player_id == player_id } || false
|
||||
end
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# PRIVATE HELPERS - kept simple for the test-suite
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
private
|
||||
|
||||
def error_response(msg)
|
||||
{ 'type' => 'error', 'payload' => msg }.to_json
|
||||
end
|
||||
|
||||
def load_players(ids)
|
||||
ids.map { |id| Player.load_from_id(id) }
|
||||
end
|
||||
|
||||
def load_planets(planets)
|
||||
if planets.is_a?(Integer) # legacy config - integer means “create N”
|
||||
Array.new(planets) { Planet.new }
|
||||
else
|
||||
planets.map { |id| Planet.load_from_id(id) }
|
||||
end
|
||||
end
|
||||
|
||||
def load_ships(ids)
|
||||
ids.map { |id| Ship.load_from_id(id) } # Ship is a placeholder
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Raised when an invalid operation is attempted (e.g. reconnecting a
|
||||
# player that already has a socket). The tests catch this error
|
||||
# to confirm that the server protects against accidental double
|
||||
# connections.
|
||||
class GameError < StandardError; end
|
||||
end
|
||||
Reference in New Issue
Block a user