diff --git a/data/libs/Ship.lua b/data/libs/Ship.lua index f77f1f3b5..b75c45aba 100644 --- a/data/libs/Ship.lua +++ b/data/libs/Ship.lua @@ -292,6 +292,35 @@ Ship.RemoveEquip = function (self, item, count, slot) return ret end +-- +-- Method: GetCargo +-- +-- Build a table that maps the names of stored cargo items to the quantity of +-- the items stored in the ship. +-- +-- > cargo = ship:GetCargo() +-- +-- Return: +-- +-- The table that maps the names of stored cargo items to the quantity of +-- the items stored in the ship. +-- +-- Availability: +-- +-- 2021 +-- +-- Status: +-- +-- experimental +-- +function Ship:GetCargo() + local count = {} + for _, et in pairs(self:GetEquip("cargo")) do + if not count[et] then count[et] = 0 end + count[et] = count[et]+1 + end + return count +end -- -- Method: IsHyperjumpAllowed diff --git a/data/libs/utils.lua b/data/libs/utils.lua index 55e5495b7..3a7117615 100644 --- a/data/libs/utils.lua +++ b/data/libs/utils.lua @@ -2,6 +2,7 @@ -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt local utils +local Engine = require 'Engine' -- rand utils = {} -- @@ -244,7 +245,7 @@ end -- Count the number of entries in a table utils.count = function(t) local i = 0 - for _,v in pairs(t) do + for _,_ in pairs(t) do i = i + 1 end return i @@ -285,4 +286,149 @@ utils.round = function(x, n) x = math.round(math.abs(x)/n)*n return x < n and n*s or x*s end + +-- +-- Function: utils.deviation +-- +-- Returns a random value that differs from nominal by no more than nominal * ratio. +-- +-- value = utils.deviation(nominal, ratio) +-- +-- Return: +-- +-- value - number +-- +-- Parameters: +-- +-- nominal - number +-- ratio - number, indicating the relative deviation of the result +-- +-- Example: +-- +-- > value = utils.deviation(100, 0.2) -- 80 < value < 120 +-- +utils.deviation = function(nominal, ratio) + return nominal * Engine.rand:Number(1 - ratio, 1 + ratio) +end + +-- +-- Function: utils.asymptote +-- +-- The function is used to limit the value of the argument, but softly. +-- See the desctription of the arguments. +-- +-- value = utils.asymptote(x, max_value, equal_to_ratio) +-- +-- Return: +-- +-- value - number +-- +-- Parameters: +-- +-- x - number +-- max_value - the return value will never be greater than this number, it +-- will asymptotically approach it +-- equal_to_ratio - 0.0 .. 1.0, the ratio between x and max_value up to which x is returned as is +-- +-- Example: +-- +-- > value = utils.asymptote(10, 100, 0.5) -- return 10 +-- > value = utils.asymptote(70, 100, 0.5) -- return 64.285 +-- > value = utils.asymptote(700, 100, 0.5) -- return 96.428 +-- > value = utils.asymptote(70000, 100, 0.5) -- return 99.964 +-- +utils.asymptote = function(x, max_value, equal_to_ratio) + local equal_to = max_value * equal_to_ratio + local equal_from = max_value - equal_to + if x < equal_to then + return x + else + return (1 - 1 / ((x - equal_to) / equal_from + 1)) * equal_from + equal_to + end +end + +-- +-- Function: utils.normWeights +-- +-- the input is an array of hashtables with an arbitrary real number in the +-- weight key. Weights are recalculated so that the sum of the weights in the +-- entire array equals 1 in fact, now these are the probabilities of selecting +-- an item in the array +-- +-- Example: +-- +-- > utils.normWeights({ {param = 10, weight = 3.4}, +-- > {param = 15, weight = 2.1} }) +-- +-- Parameters: +-- +-- array - an array of similar hashtables with an arbitrary real number in +-- the weight key +-- +-- Returns: +-- +-- nothing +-- +utils.normWeights = function(array) + local sum = 0 + for _,v in ipairs(array) do + sum = sum + v.weight + end + for _,v in ipairs(array) do + v.weight = v.weight / sum + end +end + +-- +-- Function: utils.chooseNormalized +-- +-- Choose random item, considering the weights (probabilities). +-- Each array[i] should have 'weight' key. +-- The sum of the weights must be equal to 1. +-- +-- Example: +-- +-- > my_param = utils.chooseNormalized({ {param = 10, weight = 0.62}, +-- > {param = 15, weight = 0.38} }).param +-- +-- Parameters: +-- +-- array - an array of hashtables with an arbitrary real number in +-- the weight key +-- +-- Returns: +-- +-- a random element of the array, with the probability specified in the weight key +-- +utils.chooseNormalized = function(array) + local choice = Engine.rand:Number(1.0) + sum = 0 + for _, option in ipairs(array) do + sum = sum + option.weight + if choice <= sum then return option end + end +end + +-- +-- Function: utils.chooseEqual +-- +-- Returns a random element of an array +-- +-- Example: +-- +-- > my_param = utils.chooseEqual({ {param = 10}, +-- > {param = 15} }).param +-- +-- Parameters: +-- +-- array - an array of hashtables +-- +-- Returns: +-- +-- a random element of the array, with the with equal probability for any element +-- +utils.chooseEqual = function(array) + return array[Engine.rand:Integer(1, #array)] +end + return utils diff --git a/data/modules/TradeShips.lua b/data/modules/TradeShips.lua deleted file mode 100644 index b2f99371b..000000000 --- a/data/modules/TradeShips.lua +++ /dev/null @@ -1,988 +0,0 @@ --- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details --- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt - -local Engine = require 'Engine' -local Game = require 'Game' -local Space = require 'Space' -local Comms = require 'Comms' -local Timer = require 'Timer' -local Event = require 'Event' -local Serializer = require 'Serializer' -local ShipDef = require 'ShipDef' -local Ship = require 'Ship' -local utils = require 'utils' -local e = require 'Equipment' - ---[[ - trade_ships - interval - is minimum amount of time between hyperspace arrivals, - stored here as it needs to be saved; number as seconds, updated by - spawnInitialShips - ship - object returned from Space:SpawnShip* - ship_name - of this ship type; string - ATMOSHIELD - flag indicating whether the ship has at atmospheric shield: boolean - starport - at which this ship intends to dock; SpaceStation object - dest_time - arrival time from hyperspace; number as Game.time - dest_path - for hyperspace; SystemPath object, may have body index - from_path - for hyperspace; SystemPath object - delay - indicates waiting for a Timer to give next action; number - as Game.time - status - of this ship; string, one of: - hyperspace - yet to arrive or has departed - inbound - in system and given AIDockWith order - docked - currently docked or un/docking - outbound - heading away from starport before hyperspacing - fleeing - has been attacked and is trying to get away - (currently still just following whatever AI order it had) - cowering - docked after having been attacked, waiting for - attacker to go away - orbit - was unable to dock, heading to or waiting in orbit - cargo - table of cargo types and amounts currently carried; - key: Constants.EquipType string, value: number - attacker - what this was last attacked by; Body object - chance - used to determine what action to take when attacked; number - last_flee - when last action was taken, number as Game.time - no_jump - whether this has tried to hyperspace away so it only - tries once; bool - - system_updated - indicates whether the following tables have been updated - for the current system; bool, see onEnterSystem, onLeaveSystem, and - onGameStart - - from_paths - paths of systems around the current system, used to get a - from_system for ships spawned in hyperspace; indexed array of - SystemPath objects, updated by spawnInitialShips - - starports - in the current system; indexed array of SpaceStation objects, - updated by spawnInitialShips - - vacuum_starports - in the current system; indexed array of SpaceStation objects that can be - approached without atmospheric shields, updated by spawnInitialShips - - imports, exports - in the current system, indexed array of - equipment objects (from the 'Equipment' module), updated by spawnInitialShips ---]] -local trade_ships, system_updated, from_paths, starports, vacuum_starports, imports, exports - -local addFuel = function (ship) - local drive = ship:GetEquip('engine', 1) - - -- a drive must be installed - if not drive then - print(trade_ships[ship]['ship_name']..' has no drive!') - return nil - end - - -- the last character of the fitted drive is the class - -- the fuel needed for max range is the square of the drive class - local count = drive.capabilities.hyperclass ^ 2 - - -- account for fuel it already has - count = count - ship:CountEquip(e.cargo.hydrogen) - - local added = ship:AddEquip(e.cargo.hydrogen, count) - - return added -end - -local addShipEquip = function (ship) - local trader = trade_ships[ship] - local ship_type = ShipDef[trader.ship_name] - - -- add standard equipment - ship:AddEquip(e.hyperspace['hyperdrive_'..tostring(ship_type.hyperdriveClass)]) - if ShipDef[ship.shipId].equipSlotCapacity.atmo_shield > 0 then - ship:AddEquip(e.misc.atmospheric_shielding) - trader.ATMOSHIELD = true -- flag this to save function calls later - else - -- This ship cannot safely land on a planet with an atmosphere. - trader.ATMOSHIELD = false - end - ship:AddEquip(e.misc.radar) - ship:AddEquip(e.misc.autopilot) - ship:AddEquip(e.misc.cargo_life_support) - - -- add defensive equipment based on lawlessness, luck and size - local lawlessness = Game.system.lawlessness - local size_factor = ship.freeCapacity ^ 2 / 2000000 - - if Engine.rand:Number(1) - 0.1 < lawlessness then - local num = math.floor(math.sqrt(ship.freeCapacity / 50)) - - ship:CountEquip(e.misc.shield_generator) - if num > 0 then ship:AddEquip(e.misc.shield_generator, num) end - if ship_type.equipSlotCapacity.energy_booster > 0 and - Engine.rand:Number(1) + 0.5 - size_factor < lawlessness then - ship:AddEquip(e.misc.shield_energy_booster) - end - end - - -- we can't use these yet - if ship_type.equipSlotCapacity.ecm > 0 then - if Engine.rand:Number(1) + 0.2 < lawlessness then - ship:AddEquip(e.misc.ecm_advanced) - elseif Engine.rand:Number(1) < lawlessness then - ship:AddEquip(e.misc.ecm_basic) - end - end - - -- this should be rare - if ship_type.equipSlotCapacity.hull_autorepair > 0 and - Engine.rand:Number(1) + 0.75 - size_factor < lawlessness then - ship:AddEquip(e.misc.hull_autorepair) - end -end - -local addShipCargo = function (ship, direction) - local total = 0 - local empty_space = math.min(ship.freeCapacity, ship:GetEquipFree("cargo")) - local size_factor = empty_space / 20 - local ship_cargo = {} - - if direction == 'import' and #imports == 1 then - total = ship:AddEquip(imports[1], empty_space) - ship_cargo[imports[1]] = total - elseif direction == 'export' and #exports == 1 then - total = ship:AddEquip(exports[1], empty_space) - ship_cargo[exports[1]] = total - elseif (direction == 'import' and #imports > 1) or - (direction == 'export' and #exports > 1) then - - -- happens if there was very little space left to begin with (eg small - -- ship with lots of equipment). if we let it through then we end up - -- trying to add 0 cargo forever - if size_factor < 1 then - trade_ships[ship]['cargo'] = ship_cargo - return 0 - end - - while total < empty_space do - local cargo_type - - -- get random for direction - if direction == 'import' then - cargo_type = imports[Engine.rand:Integer(1, #imports)] - else - cargo_type = exports[Engine.rand:Integer(1, #exports)] - end - - -- amount based on price and size of ship - local num = math.abs(Game.system:GetCommodityBasePriceAlterations(cargo_type.name)) * size_factor - num = Engine.rand:Integer(num, num * 2) - - local added = ship:AddEquip(cargo_type, num) - if ship_cargo[cargo_type] == nil then - ship_cargo[cargo_type] = added - else - ship_cargo[cargo_type] = ship_cargo[cargo_type] + added - end - total = total + added - end - end - -- if the table for direction was empty then cargo is empty and total is 0 - - trade_ships[ship]['cargo'] = ship_cargo - return total -end - -local doUndock -doUndock = function (ship) - -- the player may have left the system or the ship may have already undocked - if ship:exists() and ship:GetDockedWith() then - local trader = trade_ships[ship] - if not ship:Undock() then - -- unable to undock, try again in ten minutes - trader['delay'] = Game.time + 600 - Timer:CallAt(trader.delay, function () doUndock(ship) end) - else - trader['delay'] = nil - end - end -end - -local doOrbit = function (ship) - local trader = trade_ships[ship] - local sbody = trader.starport.path:GetSystemBody() - local body = Space.GetBody(sbody.parent.index) - ship:AIEnterLowOrbit(body) - trader['status'] = 'orbit' - print(ship.label..' ordering orbit of '..body.label) -end - -local getNearestStarport = function (ship, current) - if #starports == 0 then return nil end - - local trader = trade_ships[ship] - - -- Find the nearest starport that we can land at (other than current) - local starport, distance - for i = 1, #starports do - local next_starport = starports[i] - if next_starport ~= current then - local next_distance = ship:DistanceTo(next_starport) - local next_canland = (trader.ATMOSHIELD or - (next_starport.type == 'STARPORT_ORBITAL') or - (not next_starport.path:GetSystemBody().parent.hasAtmosphere)) - - if next_canland and ((starport == nil) or (next_distance < distance)) then - starport, distance = next_starport, next_distance - end - end - end - return starport or current -end - -local getSystem = function (ship) - local max_range = ship.hyperspaceRange; - if max_range > 30 then - max_range = 30 - end - local min_range = max_range / 2; - if min_range < 7.5 then - min_range = 7.5 - end - local systems_in_range = Game.system:GetNearbySystems(min_range) - if #systems_in_range == 0 then - systems_in_range = Game.system:GetNearbySystems(max_range) - end - if #systems_in_range == 0 then return nil end - if #systems_in_range == 1 then - return systems_in_range[1].path - end - - local target_system = nil - local best_prices = 0 - - -- find best system for cargo - for _, next_system in ipairs(systems_in_range) do - if #next_system:GetStationPaths() > 0 then - local next_prices = 0 - for cargo, count in pairs(trade_ships[ship]['cargo']) do - next_prices = next_prices + (next_system:GetCommodityBasePriceAlterations(cargo.name) * count) - end - if next_prices > best_prices then - target_system, best_prices = next_system, next_prices - end - end - end - - if target_system == nil then - -- pick a random system as fallback - target_system = systems_in_range[Engine.rand:Integer(1, #systems_in_range)] - - -- get closer systems - local systems_half_range = Game.system:GetNearbySystems(min_range) - - if #systems_half_range > 1 then - target_system = systems_half_range[Engine.rand:Integer(1, #systems_half_range)] - end - end - - -- pick a random starport, if there are any, so the game can simulate - -- travel to it if player arrives after (see Space::DoHyperspaceTo) - local target_starport_paths = target_system:GetStationPaths() - if #target_starport_paths > 0 then - return target_starport_paths[Engine.rand:Integer(1, #target_starport_paths)] - end - - return target_system.path -end - -local jumpToSystem = function (ship, target_path) - if target_path == nil then return nil end - - local status, fuel, duration = ship:HyperjumpTo(target_path) - - if status ~= 'OK' then - print(trade_ships[ship]['ship_name']..' jump status is not OK') - return status - end - - -- update table for ship - trade_ships[ship]['status'] = 'hyperspace' - trade_ships[ship]['starport'] = nil - trade_ships[ship]['dest_time'] = Game.time + duration - trade_ships[ship]['dest_path'] = target_path - trade_ships[ship]['from_path'] = Game.system.path - return status -end - -local getSystemAndJump = function (ship) - -- attention all coders: trade_ships[ship].starport may be nil - if trade_ships[ship].starport then - local body = Space.GetBody(trade_ships[ship].starport.path:GetSystemBody().parent.index) - local port = trade_ships[ship].starport - -- boost away from the starport before jumping if it is too close - if (ship:DistanceTo(port) < 20000) then - ship:AIEnterLowOrbit(body) - end - return jumpToSystem(ship, getSystem(ship)) - end -end - -local getAcceptableShips = function () - -- only accept ships with enough capacity that are capable of landing in atmospheres - local filter_function - if #vacuum_starports == 0 then - filter_function = function(k,def) - -- XXX should limit to ships large enough to carry significant - -- cargo, but we don't have enough ships yet - return def.tag == 'SHIP' and def.hyperdriveClass > 0 and def.equipSlotCapacity.atmo_shield > 0 - end - else - filter_function = function(k,def) - -- XXX should limit to ships large enough to carry significant - -- cargo, but we don't have enough ships yet - return def.tag == 'SHIP' and def.hyperdriveClass > 0 - end - end - return utils.build_array( - utils.map(function (k,def) - return k,def.id - end, - utils.filter(filter_function, - pairs(ShipDef) - ))) -end - -local spawnInitialShips = function (game_start) - -- quicker checks first - -- dont spawn tradeships in unpopulated systems - local population = Game.system.population - if population == 0 then - starports = {} - return nil - end - - -- check if the current system can be traded in - starports = Space.GetBodies(function (body) return body.superType == 'STARPORT' end) - if #starports == 0 then return nil end - vacuum_starports = Space.GetBodies(function (body) - return body.superType == 'STARPORT' and (body.type == 'STARPORT_ORBITAL' or (not body.path:GetSystemBody().parent.hasAtmosphere)) - end) - - -- get ships listed as tradeships, if none - give up - local ship_names = getAcceptableShips() - if #ship_names == 0 then return nil end - - -- get a measure of the market size and build lists of imports and exports - local import_score, export_score = 0, 0 - imports, exports = {}, {} - for key, equip in pairs(e.cargo) do - local v = Game.system:GetCommodityBasePriceAlterations(key) - if key ~= "rubbish" and key ~= "radioactives" and Game.system:IsCommodityLegal(key) then - -- values from SystemInfoView::UpdateEconomyTab - - if v > 4 then - import_score = import_score + (v > 10 and 2 or 1) -- lua is crazy - table.insert(imports, equip) - end - - if v < -4 then - export_score = export_score + (v < -10 and 2 or 1) - table.insert(exports, equip) - end - end - end - -- if there is no market then there is no trade - if #imports == 0 or #exports == 0 then return nil end - - -- determine how many trade ships to spawn - local lawlessness = Game.system.lawlessness - -- start with three ships per two billion population - local num_trade_ships = population * 1.5 - -- add the average of import_score and export_score - num_trade_ships = num_trade_ships + (import_score + export_score) / 2 - -- reduce based on lawlessness - num_trade_ships = num_trade_ships * (1 - lawlessness) - -- vary by up to twice as many with a bell curve probability - num_trade_ships = num_trade_ships * (Engine.rand:Number(0.25, 1) + Engine.rand:Number(0.25, 1)) - -- compute distance and interval between ships - -- the base number of AU between ships spawned in space - local range = (9 / (num_trade_ships * 0.75)) - if game_start then - range = range * 1.5 - end - -- the base number of seconds between ships spawned in hyperspace - trade_ships['interval'] = (864000 / (num_trade_ships / 4)) - -- get nearby system paths for hyperspace spawns to come from - local from_systems, dist = {}, 10 - while #from_systems < 10 do - from_systems = Game.system:GetNearbySystems(dist) - dist = dist + 5 - end - from_paths = {} - for _, system in ipairs(from_systems) do - table.insert(from_paths, system.path) - end - - -- spawn the initial trade ships - for i = 0, num_trade_ships do - -- get the name of a ship, for example 'imperial_courier' - local ship_name = ship_names[Engine.rand:Integer(1, #ship_names)] - local can_equip_atmo = ShipDef[ship_name].equipSlotCapacity.atmo_shield > 0 - local ship = nil - - if game_start and i < num_trade_ships / 4 then - -- spawn the first quarter in port if at game start - local starport = nil - if can_equip_atmo then - starport = starports[Engine.rand:Integer(1, #starports)] - elseif #vacuum_starports then - starport = vacuum_starports[Engine.rand:Integer(1, #vacuum_starports)] - end - if starport then - local dockstatus = 'docked' - ship = Space.SpawnShipDocked(ship_name, starport) - if ship == nil then - -- the starport must have been full - ship = Space.SpawnShipNear(ship_name, starport, 10000000, 149598000) -- 10mkm - 1AU - dockstatus = 'inbound' - end - trade_ships[ship] = { status = dockstatus, starport = starport, ship_name = ship_name } - ship:SetLabel(Ship.MakeRandomLabel()) - addShipEquip(ship) - end - elseif i < num_trade_ships * 0.75 then - -- spawn the first three quarters in space, or middle half if game start - local min_dist = range * i + 1 - if game_start then - min_dist = min_dist - (range * (num_trade_ships / 4)) - end - - ship = Space.SpawnShip(ship_name, min_dist, min_dist + range) - ship:SetLabel(Ship.MakeRandomLabel()) - trade_ships[ship] = { status = 'inbound', ship_name = ship_name } - -- Add ship equipment right now, because... - addShipEquip(ship) - -- ...this next call needs to see if there's an atmospheric shield. - trade_ships[ship].starport = getNearestStarport(ship) - else - -- spawn the last quarter in hyperspace - local min_time = trade_ships.interval * (i - num_trade_ships * 0.75) - local max_time = min_time + trade_ships.interval - local dest_time = Game.time + Engine.rand:Integer(min_time, max_time) - local from = from_paths[Engine.rand:Integer(1, #from_paths)] - - ship = Space.SpawnShip(ship_name, 9, 11, {from, dest_time}) - ship:SetLabel(Ship.MakeRandomLabel()) - trade_ships[ship] = { - status = 'hyperspace', - dest_time = dest_time, - dest_path = Game.system.path, - from_path = from, - ship_name = ship_name, - } - addShipEquip(ship) - end - if ship then - local trader = trade_ships[ship] - - -- add cargo - local fuel_added = addFuel(ship) - if trader.status == 'docked' then - local delay = fuel_added + addShipCargo(ship, 'export') - -- have ship wait 30-45 seconds per unit of cargo - if delay > 0 then - trader['delay'] = Game.time + (delay * Engine.rand:Number(30, 45)) - else - trader['delay'] = Game.time + Engine.rand:Number(600, 3600) - end - Timer:CallAt(trader.delay, function () doUndock(ship) end) - else - addShipCargo(ship, 'import') - -- remove fuel used to get here - if fuel_added and fuel_added > 0 then - ship:RemoveEquip(e.cargo.hydrogen, Engine.rand:Integer(1, fuel_added)) - end - if trader.status == 'inbound' then - ship:AIDockWith(trader.starport) - end - end - end - end - - return num_trade_ships -end - -local spawnReplacement = function () - -- spawn new ship in hyperspace - if #starports > 0 and Game.system.population > 0 and #imports > 0 and #exports > 0 then - local ship_names = getAcceptableShips() - local ship_name = ship_names[Engine.rand:Integer(1, #ship_names)] - - local dest_time = Game.time + Engine.rand:Number(trade_ships.interval, trade_ships.interval * 2) - local from = from_paths[Engine.rand:Integer(1, #from_paths)] - - local ship = Space.SpawnShip(ship_name, 9, 11, {from, dest_time}) - ship:SetLabel(Ship.MakeRandomLabel()) - trade_ships[ship] = { - status = 'hyperspace', - dest_time = dest_time, - dest_path = Game.system.path, - from_path = from, - ship_name = ship_name, - } - - addShipEquip(ship) - local fuel_added = addFuel(ship) - addShipCargo(ship, 'import') - if fuel_added and fuel_added > 0 then - ship:RemoveEquip(e.cargo.hydrogen, Engine.rand:Integer(1, fuel_added)) - end - end -end - -local updateTradeShipsTable = function () - local total, removed = 0, 0 - for ship, trader in pairs(trade_ships) do - total = total + 1 - if trader.status == 'hyperspace' then - -- remove ships not coming here - if not trader.dest_path:IsSameSystem(Game.system.path) then - trade_ships[ship] = nil - removed = removed + 1 - end - else - -- remove ships that are not in hyperspace - trade_ships[ship] = nil - removed = removed + 1 - end - end - print('updateTSTable:total:'..total..',removed:'..removed) -end - -local cleanTradeShipsTable = function () - local total, hyperspace, removed = 0, 0, 0 - for ship, trader in pairs(trade_ships) do - if ship ~= 'interval' then - total = total + 1 - if trader.status == 'hyperspace' then - hyperspace = hyperspace + 1 - -- remove well past due ships as the player can not catch them - if trader.dest_time + 86400 < Game.time then - trade_ships[ship] = nil - removed = removed + 1 - end - end - end - end - print('cleanTSTable:total:'..total..',active:'..total - hyperspace..',removed:'..removed) -end - -local onEnterSystem = function (ship) - -- dont crash when entering unexplored systems - if Game.system.explored == false then - return - end - - -- if the player is following a ship through hyperspace that ship may enter first - -- so update the system when the first ship enters (see Space::DoHyperspaceTo) - if not system_updated then - updateTradeShipsTable() - spawnInitialShips(false) - system_updated = true - end - - if trade_ships[ship] ~= nil then - local trader = trade_ships[ship] - print(ship.label..' '..trader.ship_name..' entered '..Game.system.name..' from '..trader.from_path:GetStarSystem().name) - - local starport = getNearestStarport(ship) - if starport then - ship:AIDockWith(starport) - trade_ships[ship]['starport'] = starport - trade_ships[ship]['status'] = 'inbound' - else - -- starport == nil happens if player has followed ship to empty system, or - -- no suitable port found (e.g. all stations atmospheric for ship without atmoshield) - getSystemAndJump(ship) - -- if we couldn't reach any systems wait for player to attack - end - end -end -Event.Register("onEnterSystem", onEnterSystem) - -local onLeaveSystem = function (ship) - if ship:IsPlayer() then - -- the next onEnterSystem will be in a new system - system_updated = false - trade_ships['interval'] = nil - - local total, removed = 0, 0 - for t_ship, trader in pairs(trade_ships) do - total = total + 1 - if trader.status == 'hyperspace' then - if trader.dest_path:IsSameSystem(Game.system.path) then - -- remove ships that are in hyperspace to here - trade_ships[t_ship] = nil - removed = removed + 1 - end - else - -- remove all ships that are not in hyperspace - trade_ships[t_ship] = nil - removed = removed + 1 - end - end - print('onLeaveSystem:total:'..total..',removed:'..removed) - elseif trade_ships[ship] ~= nil then - local system = trade_ships[ship]['dest_path']:GetStarSystem() - print(ship.label..' left '..Game.system.name..' for '..system.name) - cleanTradeShipsTable() - spawnReplacement() - end -end -Event.Register("onLeaveSystem", onLeaveSystem) - -local onFrameChanged = function (ship) - if not ship:isa("Ship") or trade_ships[ship] == nil then return end - local trader = trade_ships[ship] - - if trader.status == 'outbound' then - -- the cloud inherits the ship velocity and vector - ship:CancelAI() - if getSystemAndJump(ship) ~= 'OK' then - ship:AIDockWith(trader.starport) - trader['status'] = 'inbound' - end - end -end -Event.Register("onFrameChanged", onFrameChanged) - -local onShipDocked = function (ship, starport) - if trade_ships[ship] == nil then return end - local trader = trade_ships[ship] - - print(ship.label..' docked with '..starport.label..' ship:'..trader.ship_name) - - if trader.status == 'fleeing' then - trader['status'] = 'cowering' - else - trader['status'] = 'docked' - end - if trader.chance then - trader['chance'] = trader.chance / 2 - trader['last_flee'], trader['no_jump'] = nil, nil - end - - -- 'sell' trade cargo - local delay = 0 - for cargo, _ in pairs(trader.cargo) do - delay = delay + ship:RemoveEquip(cargo, 1000000) - end - - local damage = ShipDef[trader.ship_name].hullMass - - ship.hullMassLeft - if damage > 0 then - ship:SetHullPercent() - addShipEquip(ship) - damage = damage * 4 - end - addFuel(ship) - delay = delay + addShipCargo(ship, 'export') - if damage > delay then delay = damage end - - -- delay undocking by 30-45 seconds for every unit of cargo transfered - -- or 2-3 minutes for every unit of hull repaired - if delay > 0 then - trader['delay'] = Game.time + (delay * Engine.rand:Number(30, 45)) - else - trader['delay'] = Game.time + Engine.rand:Number(600, 3600) - end - - if trader.status == 'docked' then - Timer:CallAt(trader.delay, function () doUndock(ship) end) - end -end -Event.Register("onShipDocked", onShipDocked) - -local onShipUndocked = function (ship, starport) - if trade_ships[ship] == nil then return end - - -- fly to the limit of the starport frame - ship:AIFlyTo(starport) - - trade_ships[ship]['status'] = 'outbound' -end -Event.Register("onShipUndocked", onShipUndocked) - -local onAICompleted = function (ship, ai_error) - if trade_ships[ship] == nil then return end - local trader = trade_ships[ship] - if ai_error ~= 'NONE' then - print(ship.label..' AICompleted: Error: '..ai_error..' Status: '..trader.status) end - - if trader.status == 'outbound' then - if getSystemAndJump(ship) ~= 'OK' then - ship:AIDockWith(trader.starport) - trader['status'] = 'inbound' - end - elseif trader.status == 'orbit' then - if ai_error == 'NONE' then - trader['delay'] = Game.time + 21600 -- 6 hours - Timer:CallAt(trader.delay, function () - if ship:exists() and ship.flightState ~= 'HYPERSPACE' then - trader['starport'] = getNearestStarport(ship, trader.starport) - ship:AIDockWith(trader.starport) - trader['status'] = 'inbound' - trader['delay'] = nil - end - end) - end - -- XXX if ORBIT_IMPOSSIBLE asteroid? get parent of parent and attempt orbit? - elseif trader.status == 'inbound' then - if ai_error == 'REFUSED_PERM' then doOrbit(ship) end - end -end -Event.Register("onAICompleted", onAICompleted) - -local onShipLanded = function (ship, body) - if trade_ships[ship] == nil then return end - print(ship.label..' Landed: '..trade_ships[ship].starport.label) - - doOrbit(ship) -end -Event.Register("onShipLanded", onShipLanded) - -local onShipAlertChanged = function (ship, alert) - if trade_ships[ship] == nil then return end - if alert == 'SHIP_FIRING' then - print(ship.label..' alert changed to '..alert) end - local trader = trade_ships[ship] - if trader.attacker == nil then return end - - if alert == 'NONE' or not trader.attacker:exists() or - (alert == 'SHIP_NEARBY' and ship:DistanceTo(trader.attacker) > 100) then - trader['attacker'] = nil - if trader.status == 'fleeing' then - -- had not reached starport yet - trader['status'] = 'inbound' - elseif trader.status == 'cowering' then - -- already reached starport and docked - trader['status'] = 'docked' - if trader.delay > Game.time then - --[[ not ready to undock, so schedule it - there is a slight chance that the status was changed while - onShipDocked was in progress so fire a bit later ]] - Timer:CallAt(trader.delay + 120, function () doUndock(ship) end) - else - -- ready to undock - doUndock(ship) - end - end - end -end -Event.Register("onShipAlertChanged", onShipAlertChanged) - -local onShipHit = function (ship, attacker) - if attacker == nil then return end-- XX - - -- XXX this whole thing might be better if based on amount of damage sustained - if trade_ships[ship] == nil then return end - local trader = trade_ships[ship] - - trader['chance'] = trader.chance or 0 - trader['chance'] = trader.chance + 0.1 - - -- don't spam actions - if trader.last_flee and Game.time - trader.last_flee < Engine.rand:Integer(5, 7) then return end - - -- if outbound jump now - if trader.status == 'outbound' then - if getSystemAndJump(ship) == 'OK' then - return - end - end - - trader['status'] = 'fleeing' - trader['attacker'] = attacker - - -- update last_flee - trader['last_flee'] = Game.time - - -- if distance to starport is far attempt to hyperspace - if trader.no_jump ~= true then - if #starports == 0 then - trader['no_jump'] = true -- it already tried in onEnterSystem - elseif trader.starport and Engine.rand:Number(1) < trader.chance then - local distance = ship:DistanceTo(trader.starport) - if distance > 149598000 * (2 - trader.chance) then -- 149,598,000km = 1AU - if getSystemAndJump(ship) then - return - else - trader['no_jump'] = true - trader['chance'] = trader.chance + 0.3 - end - end - end - end - - -- maybe jettison a bit of cargo - if Engine.rand:Number(1) < trader.chance then - local cargo_type = nil - local max_cap = ShipDef[ship.shipId].capacity - for k, v in pairs(trader.cargo) do - if v > 1 and Engine.rand:Number(1) < v / max_cap then - cargo_type = k - break - end - end - if cargo_type and ship:Jettison(cargo_type) then - trader.cargo[cargo_type] = trader.cargo[cargo_type] - 1 - Comms.ImportantMessage(attacker.label..', take this and leave us be, you filthy pirate!', ship.label) - trader['chance'] = trader.chance - 0.1 - end - end -end -Event.Register("onShipHit", onShipHit) - -local onShipCollided = function (ship, other) - if trade_ships[ship] == nil then return end - if other:isa('CargoBody') then return end - - if other:isa('Ship') and other:IsPlayer() then - onShipHit(ship, other) - return - end - - -- try to get away from body, onAICompleted will take over if we succeed - ship:AIFlyTo(other) -end -Event.Register("onShipCollided", onShipCollided) - -local onShipDestroyed = function (ship, attacker) - if attacker == nil then return end-- XX - if trade_ships[ship] ~= nil then - local trader = trade_ships[ship] - - print(ship.label..' destroyed by '..attacker.label..', status:'..trader.status..' ship:'..trader.ship_name..', starport:'..(trader.starport and trader.starport.label or 'N/A')) - trade_ships[ship] = nil - - if not attacker:isa("Ship") then - spawnReplacement() - end - -- XXX consider spawning some CargoBodies if killed by a ship - else - for t_ship, trader in pairs(trade_ships) do - if t_ship ~= 'interval' and trader.attacker and trader.attacker == ship then - trader['attacker'] = nil - if trader.status == 'fleeing' then - -- had not reached starport yet - trader['status'] = 'inbound' - elseif trader.status == 'cowering' then - -- already reached starport and docked - trader['status'] = 'docked' - - if trader.delay > Game.time then - --[[ not ready to undock, so schedule it - there is a slight chance that the status was changed while - onShipDocked was in progress so fire a bit later ]] - Timer:CallAt(trader.delay + 120, function () doUndock(t_ship) end) - else - -- ready to undock - doUndock(t_ship) - end - end - return - end - end - end -end -Event.Register("onShipDestroyed", onShipDestroyed) - -local onGameStart = function () - -- create tables for data on the current system - from_paths, starports, imports, exports = {}, {}, {}, {} - - system_updated = true - - if trade_ships == nil then - -- create table to hold ships, keyed by ship object - trade_ships = {} - spawnInitialShips(true) - else - -- trade_ships was loaded by unserialize - -- rebuild starports, imports and exports tables - starports = Space.GetBodies(function (body) return body.superType == 'STARPORT' end) - vacuum_starports = Space.GetBodies(function (body) - return body.superType == 'STARPORT' and (body.type == 'STARPORT_ORBITAL' or (not body.path:GetSystemBody().parent.hasAtmosphere)) - end) - if #starports == 0 then - -- there are no starports so don't bother looking for goods - return - else - for key,equip in pairs(e.cargo) do - local v = Game.system:GetCommodityBasePriceAlterations(key) - if key ~= 'rubbish' and key ~= 'radioactives' and Game.system:IsCommodityLegal(key) then - if v > 4 then - table.insert(imports, equip) - elseif v < -4 then - table.insert(exports, equip) - end - end - end - end - - -- rebuild nearby system paths for hyperspace spawns to come from - local from_systems, dist = {}, 10 - while #from_systems < 10 do - from_systems = Game.system:GetNearbySystems(dist) - dist = dist + 5 - end - from_paths = {} - for _, system in ipairs(from_systems) do - table.insert(from_paths, system.path) - end - - -- check if any trade ships were waiting on a timer - for ship, trader in pairs(trade_ships) do - if ship ~= 'interval' and trader.delay and trader.delay > Game.time then - if trader.status == 'docked' then - Timer:CallAt(trader.delay, function () doUndock(ship) end) - elseif trader.status == 'orbit' then - Timer:CallAt(trader.delay, function () - if ship:exists() and ship.flightState ~= 'HYPERSPACE' then - trader['starport'] = getNearestStarport(ship) - ship:AIDockWith(trader.starport) - trader['status'] = 'inbound' - trader['delay'] = nil - end - end) - end - end - end - end -end -Event.Register("onGameStart", onGameStart) - -local onGameEnd = function () - -- drop the references for our data so Lua can free them - -- and so we can start fresh if the player starts another game - trade_ships, system_updated, from_paths, starports, vacuum_starports, imports, exports = nil, nil, nil, nil, nil, nil, nil -end -Event.Register("onGameEnd", onGameEnd) - -local serialize = function () - -- all we need to save is trade_ships, the rest can be rebuilt on load - - -- The serializer will crash if we try to serialize dead objects (issue #3123) - -- also, trade_ships may be nil, because it is cleared in 'onGameEnd', and this may - -- happen before the autosave module creates its '_exit' save - if trade_ships ~= nil then - local count = 0 - for k,v in pairs(trade_ships) do - if type(k) == 'userdata' and not k:exists() then - count = count + 1 - -- according to the Lua manual, removing items during iteration with pairs() or next() is ok - trade_ships[k] = nil - end - end - print('TradeShips: Removed ' .. count .. ' ships before serialization') - end - return trade_ships -end - -local unserialize = function (data) - trade_ships = data -end - -Serializer:Register("TradeShips", serialize, unserialize) diff --git a/data/modules/TradeShips/Core.lua b/data/modules/TradeShips/Core.lua new file mode 100644 index 000000000..21192a171 --- /dev/null +++ b/data/modules/TradeShips/Core.lua @@ -0,0 +1,200 @@ +-- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local Game = require 'Game' +local Serializer = require 'Serializer' + +-- this module is responsible for storing global variables, logging, serialization +local Core = { + -- CONSTANTS / SETTINGS + AU = 149598000000.0, -- meters + + MAX_ROUTE_FLOW = 3, -- flow to the most popular station in the system, ships/hour + MAX_BUSY = 0.4, -- maximum station load + MAX_SHIPS = 500, -- ~ maximim ships in open space (limitation for performance reasons) + MIN_STATION_DOCKING_TIME = 2, -- hours, minimum average time of the ship's stay at the station + + WAIT_FOR_NEXT_UNDOCK = 600, -- seconds + MINIMUM_NEAR_SYSTEMS = 10, + LOG_SIZE = 2000, -- records in log + + -- for debug info + last_spawn_interval = nil, +} + +--[[ + Property: Core.ships + (serializable table) + - object returned from Space:SpawnShip* + : hashtable + ship_name - of this ship type; string + starport - at which this ship intends to dock; SpaceStation object + dest_time - arrival time from hyperspace; number as Game.kjtime + dest_path - for hyperspace; SystemPath object, may have body index + from_path - for hyperspace; SystemPath object + delay - indicates waiting for a Timer to give next action; number as Game.time + status - of this ship; string, one of: + hyperspace - yet to arrive + hyperspace_out - has departed + inbound - in system and given AIDockWith order + docked - currently docked or un/docking + outbound - heading away from starport before hyperspacing + fleeing - has been attacked and is trying to get away + (currently still just following whatever AI order it had) + cowering - docked after having been attacked, waiting for + attacker to go away + orbit - was unable to dock, heading to or waiting in orbit + attacker: hashtable + ref - what this was last attacked by; Body object + chance - used to determine what action to take when attacked; number + last_flee - when last action was taken, number as Game.time + no_jump - whether this has tried to hyperspace away so it only tries once; bool +--]] +Core.ships = nil + +--[[ + Property: Core.params + (non-serializable table - generated every time, accorging to the current system) + ship_names - list of ship names (shipId) that can actually trade in this system + total_flow - ship flow from hyperspace, (ships per hour) + local_routes - available routes lookup table by ship name + - shipId (see Ship.lua) + : hashtable + from - body + to - body + distance - from -> to + ndocks - total docks at destination port + duration - seconds + weight - in fact, the probability + flow - of ships on the route (ship per hour) + amount - average number of ships on the route + hyper_routes - available hyperspace routes lookup table by ship name + - shipId (see Ship.lua) + : hashtable + from - system path + distance - l.y. + fuel - jump fuel consumption + duration - jump duration + cloud_duration - how long before the exit from hyperspace does the cloud appear + flow - of ships on the route (ship per hour) + ships - average number of ships on the route + port_params - station parameters lookup table by it's path + - body, spacestation + : hashtable + flow - of ships to the port (ship per hour) + ndocks - total docks at destination port + landed - average number of ships landed + busy - landed / docks + time - average duration of a ship parted at a station + imports, exports - in the current system, indexed array of + equipment objects (from the 'Equipment' module), + updated by spawnInitialShips +--]] +Core.params = nil + +-- circular buffer for log messages +Core.log = { + clear = function(self) + self.data = {} + self.size = Core.LOG_SIZE + self.p = 0 + end, + + -- ship: object + -- msg: text + -- we immediately remember the name and model, because after some time the object may become inaccessible + add = function(self, ship, msg) + self.data[self.p%self.size] = { time = Game.time, ship = ship, label = ship and ship:GetLabel(), model = ship and ship:GetShipType(), msg = msg } + self.p = self.p + 1 + end, + + -- an iterator acting like ipairs + -- query - the function that receives row, if the function returns false, the row is skipped + iter = function(query) + return function(self) + local stop = self.p > self.size and self.p % self.size or 0 + if query then + return function(a, i) + while i ~= stop do + i = (i - 1 + a.size) % a.size + if query(a.data[i]) then return i, a.data[i] end + end + end, self, self.p + else + return function(a, i) + if i == stop then return nil + else + i = (i - 1 + a.size) % a.size + return i, a.data[i] + end + end, self, self.p + end + end + end +} +Core.log:clear() + +-- register of ships added as Core.ships[ship].attacker +do + local cache = {} + Core.attackers = { + add = function(trader, attacker) + -- we additionally wrap it in a table in order to refer to a link in + -- the trader, and not to the object of the attacking ship itself + trader.attacker = { ref = attacker } + if not cache[attacker] then + -- make weak values so that they are automatically deleted + cache[attacker] = setmetatable({ trader.attacker }, { __mode = 'v' }) + else + -- collect links from all traders who were attacked by this ship + table.insert(cache[attacker], trader.attacker) + end + end, + -- return true if a link to this ship is in the cache, or clean this + -- key if there are no links to it left + check = function(attacker) + if cache[attacker] then + if #cache[attacker] > 0 then return true + else cache[attacker] = nil --if the table is empty - delete it + end + end + return false + end, + -- remove all keys that no longer have links + clean = function() + for k,v in pairs(cache) do + if #v == 0 then cache[k] = nil end + end + end, + getCache = function() return cache end -- for debug + } +end + +local serialize = function() + -- all we need to save is trade_ships, the rest can be rebuilt on load + -- The serializer will crash if we try to serialize dead objects (issue #3123) + -- also, trade_ships may be nil, because it is cleared in 'onGameEnd', and this may + -- happen before the autosave module creates its '_exit' save + if Core.ships ~= nil then + local count = 0 + for k,_ in pairs(Core.ships) do + if type(k) == 'userdata' and not k:exists() then + count = count + 1 + -- according to the Lua manual, removing items during iteration with pairs() or next() is ok + Core.ships[k] = nil + end + end + print('TradeShips: Removed ' .. count .. ' ships before serialization') + end + return Core.ships +end + +local unserialize = function(data) + Core.ships = data + -- made for backward compatibility with old saves + Core.ships.interval = nil +end + +Serializer:Register("TradeShips", serialize, unserialize) + +return Core diff --git a/data/modules/TradeShips/Debug.lua b/data/modules/TradeShips/Debug.lua new file mode 100644 index 000000000..59d226ffb --- /dev/null +++ b/data/modules/TradeShips/Debug.lua @@ -0,0 +1,335 @@ +-- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local ShipDef = require 'ShipDef' +local debugView = require 'pigui.views.debug' +local Engine = require 'Engine' +local e = require 'Equipment' +local Game = require 'Game' +local ui = require 'pigui.baseui' +local utils = require 'utils' +local Vector2 = _G.Vector2 +local Space = require 'Space' + +local Core = require 'modules.TradeShips.Core' + +local arrayTable = require 'pigui.libs.array-table' + +-- this module creates a tab in the debug window + +-- UTILS + +-- create a class method caller +local function method(fnc, ...) + local args = {...} + return function(x) + return x[fnc](x, table.unpack(args)) + end +end + +-- create a formatting function +local function format(fmt) + return function(x) + return string.format(fmt, x) + end +end + +-- convert lenght in meters to string in AU +local function distanceInAU(meters) + return string.format("%.2f AU", meters / Core.AU) +end + +local search_text = "" +local infosize = 0 +local statuses = { + 'inbound', + 'docked', + 'hyperspace', + 'hyperspace_out', + 'orbit', + 'fleeing', + 'outbound', + 'cowering', + 'unknown' +} + +debugView.registerTab('debug-trade-ships', function() + if not Core.ships and not Core.params then return end + if not ui.beginTabItem("Tradeships") then return end + + local function property(key, value) + ui.withStyleColors({["Text"] = ui.theme.colors.blueFrame}, function() + ui.text(key) + end) + ui.sameLine() + ui.text(value) + end + + ui.child("tradeships_as_child", Vector2(-1, -infosize), {"NoSavedSettings", "HorizontalScrollbar"}, function() + if Core.params then + if ui.collapsingHeader("System summary", {"DefaultOpen"}) then + local precalculated = {} + if Core.params.spawn_in then + for _, param in ipairs(Core.params.spawn_in) do + precalculated[param[1]] = param[2] + end + end + local number_of = {} + for _, status in ipairs(statuses) do + number_of[status] = 0 + end + local total_ships = 0 + for _, trader in pairs(Core.ships) do + if number_of[trader.status] then + number_of[trader.status] = number_of[trader.status] + 1 + else + number_of['unknown'] = number_of['unknown'] + 1 + end + total_ships = total_ships + 1 + end + + arrayTable.draw("tradeships_statuses", statuses, arrayTable.addKeys(ipairs, { + ships_fact = function(_,v) return number_of[v] end, + ships_proj = function(_,v) return precalculated[v] end, + ratio = function(_,v) return precalculated[v] and number_of[v] / precalculated[v] * 100 end + }),{ + { name = "Status", key = 1, string = true }, + { name = "Current", key = "ships_fact", }, + { name = "Calculated", key = "ships_proj", fnc = format("%.2f") }, + { name = "%", key = "ratio", fnc = format("%.2f%%") } + },{ + totals = {{status = "Total", ships_fact = total_ships}} + }) + ui.separator() + property("Total flow", string.format("%.2f ship/hour", Core.params.total_flow)) + ui.sameLine() + property("Last spawn interval", ui.Format.Duration(Core.last_spawn_interval)) + ui.sameLine() + property("Lawlessness", string.format("%.4f", Game.system.lawlessness)) + ui.sameLine() + property("Total bodies in space", #Space.GetBodies()) + end + + if ui.collapsingHeader("Stations") then + local totals = {docks = 0, busy_s = 0, landed = 0, flow = 0} + -- count the inbound ships + local inbound = {} + for _, trader in pairs(Core.ships) do + local s = trader.status == 'inbound' and trader.starport + if s then + if inbound[s] then inbound[s] = inbound[s] + 1 + else inbound[s] = 1 + end + end + end + totals.label = "Total for " .. utils.count(Core.params.port_params) .. " ports" + local obj = Game.systemView:GetSelectedObject() + local sb_selected = obj.type == Engine.GetEnumValue("ProjectableTypes", "OBJECT") and obj.base == Engine.GetEnumValue("ProjectableBases", "SYSTEMBODY") + arrayTable.draw("tradeships_stationinfo2", Core.params.port_params, arrayTable.addKeys(pairs, { + port = function(k,_) return k end, + label = function(k,_) return k:GetLabel() end, + parent = function(k,_) return k:GetSystemBody().parent.name end, + dist = function(k,_) return k:DistanceTo(k:GetSystemBody().nearestJumpable.body) end, + docks = function(k,_) totals.docks = totals.docks + k.numDocks return k.numDocks end, + busy_s = function(k,_) totals.busy_s = totals.busy_s + k.numShipsDocked return k.numShipsDocked end, + inbound = function(k,_) return inbound[k] end, + landed = function(_,v) totals.landed = totals.landed + v.landed return v.landed end, + flow = function(_,v) totals.flow = totals.flow + v.flow return v.flow end + }),{ + { name = "Port", key = "label", string = true }, + { name = "Parent", key = "parent", string = true }, + { name = "Distance", key = "dist", fnc = distanceInAU }, + { name = "Docks", key = "docks" }, + { name = "Busy", key = "busy", fnc = format("%.2f") }, + { name = "Landed", key = "busy_s" }, + { name = "Calculated", key = "landed", fnc = format("%.2f") }, + { name = "Dock time", key = "time", fnc = format("%.2fh") }, + { name = "Inbound", key = "inbound" }, + { name = "Ship flow", key = "flow", fnc = format("%.2f ship/h") }, + },{ + totals = { totals }, + callbacks = { + onClick = function(row) + Game.systemView:SetSelectedObject(Engine.GetEnumValue("ProjectableTypes", "OBJECT"), + Engine.GetEnumValue("ProjectableBases", "SYSTEMBODY"), row.port:GetSystemBody()) + end, + isSelected = function(row) + return sb_selected and Game.systemView:GetSelectedObject().ref == row.port:GetSystemBody() + end + }}) + end + + if ui.collapsingHeader("Local routes") then + for shipname, params in pairs(Core.params.local_routes) do + ui.text(" ") + ui.sameLine() + if ui.treeNode(shipname .. " (" .. #params .. ")") then + arrayTable.draw("tradeships_" .. shipname .. "_info", params, ipairs, { + { name = "From", key = "from", fnc = method("GetLabel"), string = true }, + { name = "To", key = "to", fnc = method("GetLabel"), string = true }, + { name = "Duration", key = "duration", fnc = ui.Format.Duration }, + { name = "Distance", key = "distance", fnc = distanceInAU } + }) + ui.treePop() + end + end + end + + if ui.collapsingHeader("Hyperspace routes") then + local function sysName(path) + return path:GetStarSystem().name + end + local selected_in_sectorview = Game.sectorView:GetSelectedSystemPath() + for shipname, params in pairs(Core.params.hyper_routes) do + if ui.treeNode(shipname .. " (" .. #params .. ")") then + arrayTable.draw("tradeships_" .. shipname .. "_hyperinfo", params, ipairs, { + { name = "From", key = "from", fnc = sysName, string = true }, + { name = "Distance", key = "distance", fnc = format("%.2f l.y.") }, + { name = "Fuel", key = "fuel", fnc = format("%.2f t") }, + { name = "Duration", key = "duration", fnc = ui.Format.Duration }, + { name = "Cloud Duration", key = "cloud_duration", fnc = ui.Format.Duration } + },{ + callbacks = { + onClick = function(row) + Game.sectorView:SwitchToPath(row.from) + end, + isSelected = function(row) + return row.from:IsSameSystem(selected_in_sectorview) + end + }}) + ui.treePop() + end + end + end + end + + if ui.collapsingHeader("All ships") then + local ships = {} + for ship, trader in pairs(Core.ships) do + if ship:exists() then + table.insert(ships, { + ship = ship, + label = ship:GetLabel(), + status = trader.status, + ts_error = trader.ts_error, + cargo = ship.usedCargo, + ai = ship:GetCurrentAICommand(), + model = ship.shipId, + port = trader.starport and trader.starport:GetLabel() + }) + end + end + local obj = Game.systemView:GetSelectedObject() + local ship_selected = obj.type == Engine.GetEnumValue("ProjectableTypes", "OBJECT") and obj.base == Engine.GetEnumValue("ProjectableBases", "SHIP") + arrayTable.draw("tradeships_all", ships, ipairs, { + { name = "#", key = "#" }, + { name = "Label", key = "label", string = true }, + { name = "Model", key = "model", string = true }, + { name = "Status", key = "status", string = true }, + { name = "Error", key = "ts_error", string = true }, + { name = "Cargo", key = "cargo" }, + { name = "AI", key = "ai", string = true }, + { name = "Port", key = "port", string = true } + }, + { callbacks = { + onClick = function(row) + if row.status ~= "hyperspace" and row.status ~= "hyperspace_out" then + Game.systemView:SetSelectedObject(Engine.GetEnumValue("ProjectableTypes", "OBJECT"), + Engine.GetEnumValue("ProjectableBases", "SHIP"), row.ship) + end + end, + isSelected = function(row) + return ship_selected and Game.systemView:GetSelectedObject().ref == row.ship + end + } + }) + end + if ui.collapsingHeader("Log") then + local obj = Game.systemView:GetSelectedObject() + local ship_selected = obj.type == Engine.GetEnumValue("ProjectableTypes", "OBJECT") and obj.base == Engine.GetEnumValue("ProjectableBases", "SHIP") + search_text, _ = ui.inputText("Search log", search_text, {}) + arrayTable.draw("tradeships_log", Core.log, Core.log.iter(search_text ~= "" and function (row) + return + row.label and string.match(row.label, search_text) or + row.msg and string.match(row.msg, search_text) or + row.model and string.match(row.model, search_text) + end),{ + { name = "Time", key = "time" , fnc = ui.Format.Datetime }, + { name = "Label", key = "label", string = true }, + { name = "Model", key = "model", string = true }, + { name = "Message", key = "msg", string = true } + },{ + callbacks = { + onClick = function(row) + status = Core.ships[row.ship] and Core.ships[row.ship].status + if status and status ~= "hyperspace" and status ~= "hyperspace_out" then + Game.systemView:SetSelectedObject(Engine.GetEnumValue("ProjectableTypes", "OBJECT"), + Engine.GetEnumValue("ProjectableBases", "SHIP"), row.ship) + end + end, + isSelected = function(row) + return ship_selected and obj.ref == row.ship + end + }}) + end + end) + + + infosize = ui.getCursorScreenPos().y + local obj = Game.systemView:GetSelectedObject() + if obj.type ~= Engine.GetEnumValue("ProjectableTypes", "NONE") and Core.ships[obj.ref] then + local ship = obj.ref + local trader = Core.ships[ship] + if ui.collapsingHeader("Info:", {"DefaultOpen"}) then + property("Trader: ", ship:GetShipType() .. " " .. ship:GetLabel()) + local status = trader.status + if status == "docked" then + status = status .. " (" .. trader.starport:GetLabel() .. ")" + elseif status == "inbound" then + local d,u = ui.Format.Distance(ship:DistanceTo(trader.starport)) + status = status .. " (" .. trader.starport:GetLabel() .. " - " .. d .. " " .. u .. ")" + end + property("Status: ", status) + if trader.fnc then + property("Task: ", trader.fnc .. " in " .. ui.Format.Duration(trader.delay - Game.time)) + end + property("Fuel: ", string.format("%.4f", ship.fuelMassLeft) .. "/" .. ShipDef[ship.shipId].fuelTankMass .. " t") + end + if ui.collapsingHeader("Internals:", {"DefaultOpen"}) then + local equipItems = {} + local total_mass = 0 + local equips = { "cargo", "misc", "hyperspace", "laser" } + for _,t in ipairs(equips) do + for _,et in pairs(e[t]) do + local count = ship:CountEquip(et) + if count > 0 then + local all_mass = count * et.capabilities.mass + table.insert(equipItems, { + name = et:GetName(), + eq_type = t, + count = count, + mass = et.capabilities.mass, + all_mass = all_mass + }) + total_mass = total_mass + all_mass + end + end + end + local capacity = ShipDef[ship.shipId].capacity + arrayTable.draw("tradeships_traderequipment", equipItems, ipairs, { + { name = "Name", key = "name", string = true }, + { name = "Type", key = "eq_type", string = true }, + { name = "Units", key = "count" }, + { name = "Unit's mass", key = "mass", fnc = format("%dt") }, + { name = "Total", key = "all_mass", fnc = format("%dt") } + }, + { totals = { + { name = "Total:", all_mass = total_mass }, + { name = "Capacity:", all_mass = capacity }, + { name = "Free:", all_mass = capacity - total_mass }, + }}) + end + end + infosize = ui.getCursorScreenPos().y - infosize + ui.endTabItem() +end) diff --git a/data/modules/TradeShips/Events.lua b/data/modules/TradeShips/Events.lua new file mode 100644 index 000000000..f6c666dbc --- /dev/null +++ b/data/modules/TradeShips/Events.lua @@ -0,0 +1,326 @@ +-- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local Comms = require 'Comms' +local Engine = require 'Engine' +local Event = require 'Event' +local Game = require 'Game' +local ShipDef = require 'ShipDef' +local utils = require 'utils' + +local Core = require 'modules.TradeShips.Core' +local Flow = require 'modules.TradeShips.Flow' +local Trader = require 'modules.TradeShips.Trader' + +-- this module contains all events affecting tradeships + +local onGameStart = function () + if Flow.calculateSystemParams() then + if Core.ships == nil then -- new game + Flow.spawnInitialShips() + Flow.run() + else -- deserialization + -- check if any trade ships were waiting on a timer + -- restart + for ship, trader in pairs(Core.ships) do + if trader.delay and trader.fnc and trader.delay > Game.time then + Core.log:add(ship, "Resume " .. trader.fnc) + Trader.assignTask(ship, trader.delay, trader.fnc) + end + end + Flow.run() + end + end +end +Event.Register("onGameStart", onGameStart) + +local onEnterSystem = function (ship) + -- dont crash when entering unexplored systems + if Game.system.explored == false then + return + end + + -- clearing the cache of ships that attacked traders, but are no longer relevant + Core.attackers.clean() + + -- if the player is following a ship through hyperspace that ship may enter first + -- so update the system when the first ship enters (see Space::DoHyperspaceTo) + if ship:IsPlayer() then + Flow.updateTradeShipsTable() + if Flow.calculateSystemParams() then + Flow.spawnInitialShips() + Flow.run() + end + elseif Core.ships[ship] ~= nil then + local trader = Core.ships[ship] + Core.log:add(ship, 'Entered '..Game.system.name..' from '..trader.from_path:GetStarSystem().name) + + if trader.route then + ship:AIDockWith(trader.route.to) + Core.ships[ship]['starport'] = trader.route.to + Core.ships[ship]['status'] = 'inbound' + else + -- starport == nil happens if player has followed ship to empty system, or + -- no suitable port found (e.g. all stations atmospheric for ship without atmoshield) + Trader.getSystemAndJump(ship) + -- if we couldn't reach any systems wait for player to attack + end + end +end +Event.Register("onEnterSystem", onEnterSystem) + +local onLeaveSystem = function (ship) + if ship:IsPlayer() then + -- the next onEnterSystem will be in a new system + local total, removed = 0, 0 + for t_ship, trader in pairs(Core.ships) do + total = total + 1 + if trader.status == 'hyperspace' then + if trader.dest_path:IsSameSystem(Game.system.path) then + -- remove ships that are in hyperspace to here + Core.ships[t_ship] = nil + removed = removed + 1 + end + else + -- remove all ships that are not in hyperspace + Core.ships[t_ship] = nil + removed = removed + 1 + end + end + Core.log:add(nil, 'onLeaveSystem:total:'..total..',removed:'..removed) + elseif Core.ships[ship] ~= nil then + local system = Core.ships[ship]['dest_path']:GetStarSystem() + Core.log:add(ship, 'Left '..Game.system.name..' for '.. system.name) + Flow.cleanTradeShipsTable() + end +end +Event.Register("onLeaveSystem", onLeaveSystem) + +local onFrameChanged = function (ship) + if not ship:isa("Ship") or Core.ships[ship] == nil then return end + local trader = Core.ships[ship] + Core.log:add(ship, "Entered frame " .. (ship.frameBody and ship.frameBody:GetLabel() or "unknown")) + + if trader.status == 'outbound' then + -- the cloud inherits the ship velocity and vector + ship:CancelAI() + if Trader.getSystemAndJump(ship) ~= 'OK' then + ship:AIDockWith(trader.starport) + trader['status'] = 'inbound' + trader.ts_error = 'cnt_jump_frame' + end + end +end +Event.Register("onFrameChanged", onFrameChanged) + +local onShipDocked = function (ship, starport) + if Core.ships[ship] == nil then return end + local trader = Core.ships[ship] + + trader.route = nil + Core.log:add(ship, 'Docked with '..starport.label) + + if trader.status == 'fleeing' then + trader['status'] = 'cowering' + else + trader['status'] = 'docked' + end + if trader.chance then + trader['chance'] = trader.chance / 2 + trader['last_flee'], trader['no_jump'] = nil, nil + end + + -- 'sell' trade cargo + for cargo, _ in pairs(ship:GetCargo()) do + ship:RemoveEquip(cargo, 1000000) + end + + local damage = ShipDef[trader.ship_name].hullMass - ship.hullMassLeft + if damage > 0 then + ship:SetHullPercent() + Trader.addEquip(ship) + end + Trader.addFuel(ship) + ship:SetFuelPercent() + + if trader.status == 'docked' then + Trader.assignTask(ship, Game.time + utils.deviation(Core.params.port_params[starport].time * 3600, 0.8), 'doUndock') + end +end +Event.Register("onShipDocked", onShipDocked) + +local onShipUndocked = function (ship, starport) + if Core.ships[ship] == nil then return end + + -- fly to the limit of the starport frame + ship:AIFlyTo(starport) + + Core.ships[ship]['status'] = 'outbound' +end +Event.Register("onShipUndocked", onShipUndocked) + +local onAICompleted = function (ship, ai_error) + if Core.ships[ship] == nil then return end + local trader = Core.ships[ship] + if ai_error ~= 'NONE' then + Core.log:add(ship, 'AICompleted: Error: '..ai_error..' Status: '..trader.status) end + + if trader.status == 'outbound' then + if Trader.getSystemAndJump(ship) ~= 'OK' then + ship:AIDockWith(trader.starport) + trader['status'] = 'inbound' + trader.ts_error = 'cnt_jump_aicomp' + end + elseif trader.status == 'orbit' then + if ai_error == 'NONE' then + trader.ts_error = "wait_6h" + Trader.assignTask(ship, Game.time + 21600, 'doRedock') + end + -- XXX if ORBIT_IMPOSSIBLE asteroid? get parent of parent and attempt orbit? + elseif trader.status == 'inbound' then + if ai_error == 'REFUSED_PERM' then + Trader.doOrbit(ship) + trader.ts_error = "refused_perm" + end + end +end +Event.Register("onAICompleted", onAICompleted) + +local onShipLanded = function (ship, _) + if Core.ships[ship] == nil then return end + Core.log:add(ship, 'Landed: '..Core.ships[ship].starport.label) + Trader.doOrbit(ship) +end +Event.Register("onShipLanded", onShipLanded) + +local onShipAlertChanged = function (ship, alert) + if Core.ships[ship] == nil then return end + if alert == 'SHIP_FIRING' then + Core.log:add(ship, 'Alert changed to '..alert) end + local trader = Core.ships[ship] + if trader.attacker == nil then return end + + if alert == 'NONE' or not trader.attacker.ref:exists() or + (alert == 'SHIP_NEARBY' and ship:DistanceTo(trader.attacker.ref) > 100) then + if trader.status == 'fleeing' then + -- had not reached starport yet + trader['status'] = 'inbound' + trader.ts_error = "alert!" + elseif trader.status == 'cowering' then + -- already reached starport and docked + -- we need to do everything that the ship does after docking + onShipDocked(ship, trader.starport) + end + end +end +Event.Register("onShipAlertChanged", onShipAlertChanged) + +local onShipHit = function (ship, attacker) + if attacker == nil then return end-- XX + + -- XXX this whole thing might be better if based on amount of damage sustained + if Core.ships[ship] == nil then return end + local trader = Core.ships[ship] + trader.ts_error = "HIT" + + trader['chance'] = trader.chance or 0 + trader['chance'] = trader.chance + 0.1 + + -- don't spam actions + if trader.last_flee and Game.time - trader.last_flee < Engine.rand:Integer(5, 7) then return end + + -- if outbound jump now + if trader.status == 'outbound' then + if Trader.getSystemAndJump(ship) == 'OK' then + return + end + end + + trader['status'] = 'fleeing' + Core.attackers.add(trader, attacker) + + -- update last_flee + trader['last_flee'] = Game.time + + -- if distance to starport is far attempt to hyperspace + if trader.no_jump ~= true then + local starports = Core.params.local_routes + if #starports == 0 then + trader['no_jump'] = true -- it already tried in onEnterSystem + elseif trader.starport and Engine.rand:Number(1) < trader.chance then + local distance = ship:DistanceTo(trader.starport) + if distance > Core.AU * (2 - trader.chance) then + if Trader.getSystemAndJump(ship) then + return + else + trader['no_jump'] = true + trader['chance'] = trader.chance + 0.3 + end + end + end + end + + -- maybe jettison a bit of cargo + if Engine.rand:Number(1) < trader.chance then + local cargo_type = nil + local max_cap = ShipDef[ship.shipId].capacity + for k, v in pairs(ship:GetCargo()) do + if v > 1 and Engine.rand:Number(1) < v / max_cap then + cargo_type = k + break + end + end + if cargo_type and ship:Jettison(cargo_type) then + Comms.ImportantMessage(attacker.label..', take this and leave us be, you filthy pirate!', ship.label) + trader['chance'] = trader.chance - 0.1 + end + end +end +Event.Register("onShipHit", onShipHit) + +local onShipCollided = function (ship, other) + if Core.ships[ship] == nil then return end + if other:isa('CargoBody') then return end + Core.ships[ship].ts_error = "collided" + + if other:isa('Ship') and other:IsPlayer() then + onShipHit(ship, other) + return + end +end +Event.Register("onShipCollided", onShipCollided) + +local onShipDestroyed = function (ship, attacker) + if attacker == nil then return end-- XX + if Core.ships[ship] ~= nil then + local trader = Core.ships[ship] + Core.log:add(ship, 'Destroyed by '..attacker.label..', status:'..trader.status..' starport:'..(trader.starport and trader.starport.label or 'N/A')) + Core.ships[ship] = nil + -- XXX consider spawning some CargoBodies if killed by a ship + elseif Core.attackers.check(attacker) then + for t_ship, trader in pairs(Core.ships) do + if trader.attacker and trader.attacker.ref == ship then + trader.attacker = nil + if trader.status == 'fleeing' then + -- had not reached starport yet + trader['status'] = 'inbound' + trader.ts_error = "attack!" + elseif trader.status == 'cowering' then + -- already reached starport and docked + -- we need to do everything that the ship does after docking + onShipDocked(t_ship, trader.starport) + end + return + end + end + end +end +Event.Register("onShipDestroyed", onShipDestroyed) + + +local onGameEnd = function () + -- drop the references for our data so Lua can free them + -- and so we can start fresh if the player starts another game + Core.ships, Core.params = nil, nil +end +Event.Register("onGameEnd", onGameEnd) diff --git a/data/modules/TradeShips/Flow.lua b/data/modules/TradeShips/Flow.lua new file mode 100644 index 000000000..5025fe286 --- /dev/null +++ b/data/modules/TradeShips/Flow.lua @@ -0,0 +1,525 @@ +-- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local e = require 'Equipment' +local Engine = require 'Engine' +local Game = require 'Game' +local Ship = require 'Ship' +local ShipDef = require 'ShipDef' +local Space = require 'Space' +local utils = require 'utils' + +local Core = require 'modules.TradeShips.Core' +local Trader = require 'modules.TradeShips.Trader' + +local Flow = {} +-- this module creates all tradeships, and ensures the flow of new ships into the system + +-- UTILS + +-- the inbound cloud lifetime cannot be more than half the route duration, and +-- asymptotically approaches two days +local function inboundCloudDuration(route_duration) + local max_duration = 2 * 24 * 60 * 60 -- 2 days max + return utils.asymptote(route_duration / 2, max_duration, 0.0) +end + +-- change of the standard parameter depending on local factors +local function localFactors(param, system) + -- we only take into account the criminal situation + return param * (1 - system.lawlessness) +end + +-- return an array of names of ships that (at first sight) can be traders +local getAcceptableShips = function () + -- accept all ships with the hyperdrive, in fact + local filter_function = function(_,def) + -- XXX should limit to ships large enough to carry significant + -- cargo, but we don't have enough ships yet + return def.tag == 'SHIP' and def.hyperdriveClass > 0 -- and def.roles.merchant + end + return utils.build_array( + utils.map(function (k,def) + return k,def.id + end, + utils.filter(filter_function, + pairs(ShipDef) + ))) +end + +-- expand the search radius until we find a sufficient number of nearby systems +local function getNearSystems() + local from_systems, dist = {}, 10 + while #from_systems < Core.MINIMUM_NEAR_SYSTEMS do + from_systems = Game.system:GetNearbySystems(dist) + dist = dist + 5 + end + local paths = {} + for _, from_system in ipairs(from_systems) do + table.insert(paths, from_system.path) + end + return paths +end + +-- get a measure of the market size and build lists of imports and exports +local function getImportsExports(system) + local import_score, export_score = 0, 0 + local imports, exports = {}, {} + for key, equip in pairs(e.cargo) do + local v = system:GetCommodityBasePriceAlterations(key) + if key ~= "rubbish" and key ~= "radioactives" and system:IsCommodityLegal(key) then + -- values from SystemInfoView::UpdateEconomyTab + + if v > 2 then + import_score = import_score + (v > 10 and 2 or 1) + table.insert(imports, equip) + end + + if v < -4 then + export_score = export_score + (v < -10 and 2 or 1) + table.insert(exports, equip) + end + end + end + return imports, exports +end + +-- we collect all the information necessary for the module to work in this system: +-- valid ship models +-- import / export goods +-- hyperspace routes (from other stars to the star(s) of this system) +-- local routes (from the star(s) to the trade ports) +-- trade ports parameters +-- flow rate required to maintain the required number of trade ships +Flow.calculateSystemParams = function() + -- delete the old value if it was, all calculation results will be written here + Core.params = nil + + local system = Game.system + + -- dont spawn tradeships in unpopulated systems + if system.population == 0 then return nil end + + -- all ports in the system + local ports = Space.GetBodies(function (body) return body.superType == 'STARPORT' end) + -- check if the current system can be traded in + if #ports == 0 then return nil end + + -- all routes in the system + -- routes are laid from the nearest star + local routes = {} + for _,port in ipairs(ports) do + local from = port:GetSystemBody().nearestJumpable.body + local distance = from:DistanceTo(port) + table.insert(routes, { + from = from, + to = port, + distance = distance, + weight = port.numDocks / distance * Core.AU + }) + end + + -- get ships listed as tradeships, if none - give up + local ship_names = getAcceptableShips() + if #ship_names == 0 then return nil end + + local imports, exports = getImportsExports(system) + + -- if there is no market then there is no trade + if #imports == 0 or #exports == 0 then return nil end + + -- all tradeship models array + -- these are real ship objects, they are needed for calculations + -- but at the same time they will not be inserted into space, so as not to generate events, etc. + -- after this function completes, they should be eaten by the garbage collector + local dummies = {} + for _, ship_name in ipairs(ship_names) do + local dummy = Ship.Create(ship_name) + Trader.addEquip(dummy) + -- just fill it with hydrogen to the brim + dummy:AddEquip(e.cargo.hydrogen, Trader.emptySpace(dummy)) + dummies[ship_name] = dummy + end + + -- build all hyperspace inbound routes for all ships + local near_systems = getNearSystems() + local hyper_routes = {} + for i = #ship_names, 1, -1 do + local id = ship_names[i] + local this_ship_routes = {} + for _, path in ipairs(near_systems) do + local status, distance, fuel, duration = dummies[id]:GetHyperspaceDetails(path, system.path) + if status == "OK" then + table.insert(this_ship_routes, {from = path, distance = distance, fuel = fuel, duration = duration}) + end + end + if #this_ship_routes > 0 then + hyper_routes[id] = this_ship_routes + else + print("WARNING: ship " .. id .. " discarded, because there are no hyperspace routes available for it") + table.remove(ship_names, i) + end + end + + -- build all suitable local routes for all ships + local local_routes = {} + for i = #ship_names, 1, -1 do + local id = ship_names[i] + local this_ship_routes = {} + for _,route in ipairs(routes) do + -- discard route if ship can't dock/land there + if Trader.isStarportAcceptableForShip(route.to, dummies[id]) then + local duration = dummies[id]:GetDurationForDistance(route.distance) + -- this value determines the popularity of this station among merchants + -- the closer it is, and the more docks in it, the more popular it is + local weight = math.pow(route.to.numDocks, 2) / math.pow(duration, 2) + table.insert(this_ship_routes, { + from = route.from, + to = route.to, + distance = route.distance, + ndocks = route.to.numDocks, + duration = duration, + weight = weight, + }) + end + end + if #this_ship_routes == 0 then + -- no suitable local routes for this ship + print("WARNING: ship " .. id .. " discarded, because there are no local routes available for it") + table.remove(ship_names, i) + -- also erasing this ship from hyperspace routes + hyper_routes[id] = nil + else + utils.normWeights(this_ship_routes) + local_routes[id] = this_ship_routes + end + end + + -- as a result, not a single ship might remain + if #ship_names == 0 then return nil end + + -- now we have only those ships that can jump from somewhere and land somewhere + -- now set the flow on the most popular route to MAX_ROUTE_FLOW + -- let's collect all data on routes and calculate the most popular route weight + local local_routes_map = {} + for _,routes_for_ship in pairs(local_routes) do + for _, route_for_ship in ipairs(routes_for_ship) do + local current = local_routes_map[route_for_ship.to] or 0 + local_routes_map[route_for_ship.to] = current + route_for_ship.weight + end + end + local max_route_weight = 0 + for _, weight in pairs(local_routes_map) do + max_route_weight = math.max(max_route_weight, weight) + end + + -- weight to flow conversion factor + local flow_per_ship = localFactors(Core.MAX_ROUTE_FLOW, system) / max_route_weight + -- we will reduce it, depending on the legal status of the system + + local total_flow + -- calculate flow for local routes + -- and total count of ships in space + -- it may be necessary to recalculate the flow if the required number of + -- ships turns out to be too large + do + local ships_in_space + local function calculateFlowForLocalRoutes() + ships_in_space = 0 + total_flow = 0 + for _, ship_routes in pairs(local_routes) do + for _, ship_route in ipairs(ship_routes) do + ship_route.flow = flow_per_ship * ship_route.weight + total_flow = total_flow + ship_route.flow + ship_route.amount = ship_route.flow * ship_route.duration / 3600 + ships_in_space = ships_in_space + ship_route.amount + end + end + end + calculateFlowForLocalRoutes() + -- checking if we shouldn't reduce the number of ships + local allowed_ships = utils.asymptote(ships_in_space, Core.MAX_SHIPS, 0.6) + if ships_in_space > allowed_ships then + print(string.format("WARNING: lowering ships flow by %.2f for performance reasons", allowed_ships / ships_in_space)) + -- the required amount is too large + -- lowering the flow for performance purposes + flow_per_ship = flow_per_ship * allowed_ships / ships_in_space + -- recalculate for new conditions + calculateFlowForLocalRoutes() + end + end + + -- calculate station's parameters: + -- summarize the flow to the station from all suitable ships + local port_params = {} + for _, ship_routes in pairs(local_routes) do + for _, ship_route in ipairs(ship_routes) do + local to = ship_route.to + if not port_params[to] then port_params[to] = {flow = 0} end + port_params[to].flow = port_params[to].flow + ship_route.flow + end + end + + local old_total_flow = total_flow + -- now we need to prevent too much flow to the station + for port, params in pairs(port_params) do + local max_flow = port.numDocks * localFactors(Core.MAX_BUSY, system) / Core.MIN_STATION_DOCKING_TIME + if params.flow > max_flow then + local k = max_flow / params.flow + params.flow = max_flow + for _, ship_routes in pairs(local_routes) do + for _, ship_route in ipairs(ship_routes) do + if ship_route.to == port then + local old_flow = ship_route.flow + ship_route.flow = ship_route.flow * k + ship_route.weight = ship_route.weight * k + total_flow = total_flow - old_flow + ship_route.flow + end + end + end + print(port:GetLabel() .. ": the flow has been reduced so that the stay at the station is not too short") + end + end + + if old_total_flow ~= total_flow then + print("old total flow: " .. old_total_flow .. " new total flow: " .. total_flow) + for _, ship_routes in pairs(local_routes) do + utils.normWeights(ship_routes) + end + end + + -- now we know the total flow, we can calculate the flow and the number of ships in hyperspace + -- adding flow information to hyper_routes + for _, id in ipairs(ship_names) do + -- the probabilities are the same for all hyper-routes of a given ship + local p = 1 / #ship_names / #hyper_routes[id] + for _, row in ipairs(hyper_routes[id]) do + row.cloud_duration = inboundCloudDuration(row.duration) + row.flow = p * total_flow + row.ships = row.flow * row.cloud_duration / 3600 + end + end + + -- calculate station parking times + -- calculate the maximum flow to the station + local max_station_flow = 0 + for _, row in pairs(port_params) do + if row.flow > max_station_flow then + max_station_flow = row.flow + end + end + + -- conversion factor flow -> station_load + -- this is quite artifical calculation + -- we want the maximum station load (occupied pads/all pads) to be + -- and we also want ships to stay longer at less popular stations + -- to have some kind of presence + -- therefore, we make the dependence of the load on the flow not linear but radical + local flow_to_busy = localFactors(Core.MAX_BUSY, system) / math.sqrt(max_station_flow) -- conversion factor + -- we also reduce the workload of stations, depending on the criminal situation + for port, params in pairs(port_params) do + params.ndocks = port.numDocks + -- parking time cannot be less than the specified minimum + params.landed = math.max(math.sqrt(params.flow) * flow_to_busy * params.ndocks, Core.MIN_STATION_DOCKING_TIME * params.flow) + params.busy = params.landed / params.ndocks + params.time = params.landed / params.flow + end + + Core.params = { + -- list of ship names that can actually trade in this system + ship_names = ship_names, + -- market parameters + imports = imports, + exports = exports, + -- ship flow + total_flow = total_flow, + -- available routes lookup table by ship name + local_routes = local_routes, + -- available hyperspace routes lookup table by ship name + hyper_routes = hyper_routes, + -- station parameters lookup table by it's path + port_params = port_params + } + return true +end + +-- run this function at the start of the game or when jumped to another system +Flow.spawnInitialShips = function() + + if not Core.params then return nil end + + local hyper_routes = Core.params.hyper_routes + local local_routes = Core.params.local_routes + local port_params = Core.params.port_params + + -- we want to calculate how many ships of each model are in space, hyperspace + -- and stations on average + -- it is needed for initial spawn + + -- create tables for all possible ship placement options + -- table for ships flying in space + local routes_variants = {} + -- table for ships docked at station + local docked_variants = {} + -- amount of ships in space at start + local ships_in_space = 0 + for id, ship_routes in pairs(local_routes) do + for _, ship_route in ipairs(ship_routes) do + table.insert( + routes_variants, { + id = id, + from = ship_route.from, + to = ship_route.to, + weight = ship_route.amount + }) + table.insert( + docked_variants, { + id = id, + port = ship_route.to, + weight = ship_route.flow + }) + ships_in_space = ships_in_space + ship_route.amount + end + end + local port_summary = {} + for k, v in pairs(port_params) do + v.port = k + table.insert(port_summary, v) + end + utils.normWeights(routes_variants) + utils.normWeights(docked_variants) + + -- amount of ships docked at start + local ships_docked = 0 + for _, params in pairs(port_params) do + ships_docked = ships_docked + params.landed + end + + -- table for ships in inbound hyperspace cloud + local cloud_variants = {} + -- amount of ships in cloud at start + local ships_in_cloud = 0 + for id, ship_routes in pairs(hyper_routes) do + for _, ship_route in ipairs(ship_routes) do + ships_in_cloud = ships_in_cloud + ship_route.ships + table.insert(cloud_variants, { + ship = id, + from = ship_route.from, + cloud_duration = ship_route.cloud_duration, + weight = ship_route.ships + }) + end + end + utils.normWeights(cloud_variants) + + -- if we generate ships after jumping into the system, and not when starting a new game, + -- count the number of ships that were transferred to this system from the + -- source system, and reduce the need for generation by this amount + if Core.ships == nil then Core.ships = {} end + for ship, trader in pairs(Core.ships) do + if trader.status == 'hyperspace' then + ships_in_cloud = math.max(0, ships_in_cloud - 1) + -- and also assign a random port for this ship, if there are any ports for it at all + local routes_for_ship = Core.params.local_routes[ship.shipId] + if routes_for_ship then + trader.route = utils.chooseNormalized(routes_for_ship) + else + -- TODO but what if there are no ports for it? + end + end + end + + -- selection table where the ship will be at the start + local spawn_in = { + {place = "inbound", weight = ships_in_space}, + {place = "hyperspace", weight = ships_in_cloud}, + {place = "docked", weight = ships_docked} + } + -- we remember this data for output to the debug table + Core.params.spawn_in = {{"inbound", ships_in_space}, {"hyperspace", ships_in_cloud}, {"docked", ships_docked}} + utils.normWeights(spawn_in) + + -- generating ships + for _ = 1, ships_in_space + ships_in_cloud + ships_docked do + --choose place + local place = utils.chooseNormalized(spawn_in).place + + if place == "inbound" then + local params = utils.chooseNormalized(routes_variants) + local hj_route = utils.chooseEqual(hyper_routes[params.id]) + local ship = Space.SpawnShip(params.id, 9, 11, {hj_route.from, params.from:GetSystemBody().path, 0.0}) + ship:SetLabel(Ship.MakeRandomLabel()) + Core.ships[ship] = { ts_error = "OK", status = 'inbound', starport = params.to, ship_name = params.id} + Trader.addEquip(ship) + local fuel_added = Trader.addFuel(ship) + Trader.addCargo(ship, 'import') + if fuel_added and fuel_added > 0 then + ship:RemoveEquip(e.cargo.hydrogen, math.min(hj_route.fuel, fuel_added)) + end + Space.PutShipOnRoute(ship, params.to, Engine.rand:Number(0.0, 0.999))-- don't generate at the destination + ship:AIDockWith(params.to) + + elseif place == "hyperspace" then + -- choose random source system, and ship + local cloud = utils.chooseNormalized(cloud_variants) + local route = utils.chooseNormalized(Core.params.local_routes[cloud.ship]) + Trader.spawnInCloud(cloud.ship, cloud, route, Game.time + Engine.rand:Integer(1, cloud.cloud_duration)) + + else -- docked + local params = utils.chooseNormalized(docked_variants) + local ship = Space.SpawnShipDocked(params.id, params.port) + -- if can't spawn - just skip + if ship then + Core.ships[ship] = { ts_error = "OK", status = 'docked', starport = params.port, ship_name = params.id } + ship:SetLabel(Ship.MakeRandomLabel()) + Trader.addEquip(ship) + end + end + end + return ships_in_space +end + +Flow.run = function() + local ship_name = utils.chooseEqual(Core.params.ship_names) + local cloud = utils.chooseEqual(Core.params.hyper_routes[ship_name]) + -- we immediately choose the local route, because it depends on which star to + -- jump to in the multiple system + local route = utils.chooseNormalized(Core.params.local_routes[ship_name]) + Trader.spawnInCloud(ship_name, cloud, route, Game.time + utils.deviation(cloud.cloud_duration, 0.1)) + Core.last_spawn_interval = utils.deviation(3600 / Core.params.total_flow, 0.9) -- for debug info + Trader.callInThisSystem(Game.time + Core.last_spawn_interval, Flow.run) +end + +Flow.cleanTradeShipsTable = function() + local HYPERCLOUD_DURATION = 172800 -- 2 days + local total, hyperspace, removed = 0, 0, 0 + for ship, trader in pairs(Core.ships) do + total = total + 1 + if trader.status == 'hyperspace_out' then + hyperspace = hyperspace + 1 + -- remove well past due ships as the player can not catch them + if trader.jump_time + HYPERCLOUD_DURATION < Game.time then + Core.ships[ship] = nil + removed = removed + 1 + end + end + end + -- print('cleanTSTable:total:'..total..',active:'..total - hyperspace..',removed:'..removed) +end + +-- we leave only those ships that flew into this system +Flow.updateTradeShipsTable = function() + local total, removed = 0, 0 + for ship, trader in pairs(Core.ships) do + total = total + 1 + if trader.status ~= 'hyperspace_out' or not trader.dest_path:IsSameSystem(Game.system.path) then + Core.ships[ship] = nil + removed = removed + 1 + else + trader.status = 'hyperspace' + end + end + -- print('updateTSTable:total:'..total..',removed:'..removed) +end + +return Flow diff --git a/data/modules/TradeShips/Trader.lua b/data/modules/TradeShips/Trader.lua new file mode 100644 index 000000000..620fb397d --- /dev/null +++ b/data/modules/TradeShips/Trader.lua @@ -0,0 +1,338 @@ +-- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local e = require 'Equipment' +local Engine = require 'Engine' +local Game = require 'Game' +local Ship = require 'Ship' +local ShipDef = require 'ShipDef' +local Space = require 'Space' +local Timer = require 'Timer' + +local Core = require 'modules.TradeShips.Core' + +local Trader = {} +-- this module contains functions that work for single traders + +Trader.addEquip = function (ship) + assert(ship.usedCargo == 0, "equipment is only installed on an empty ship") + local ship_type = ShipDef[ship.shipId] + + -- add standard equipment + ship:AddEquip(e.hyperspace['hyperdrive_'..tostring(ship_type.hyperdriveClass)]) + if ShipDef[ship.shipId].equipSlotCapacity.atmo_shield > 0 then + ship:AddEquip(e.misc.atmospheric_shielding) + end + ship:AddEquip(e.misc.radar) + ship:AddEquip(e.misc.autopilot) + ship:AddEquip(e.misc.cargo_life_support) + + -- add defensive equipment based on lawlessness, luck and size + local lawlessness = Game.system.lawlessness + local size_factor = ship.freeCapacity ^ 2 / 2000000 + + if Engine.rand:Number(1) - 0.1 < lawlessness then + local num = math.floor(math.sqrt(ship.freeCapacity / 50)) - + ship:CountEquip(e.misc.shield_generator) + if num > 0 then ship:AddEquip(e.misc.shield_generator, num) end + if ship_type.equipSlotCapacity.energy_booster > 0 and + Engine.rand:Number(1) + 0.5 - size_factor < lawlessness then + ship:AddEquip(e.misc.shield_energy_booster) + end + end + + -- we can't use these yet + if ship_type.equipSlotCapacity.ecm > 0 then + if Engine.rand:Number(1) + 0.2 < lawlessness then + ship:AddEquip(e.misc.ecm_advanced) + elseif Engine.rand:Number(1) < lawlessness then + ship:AddEquip(e.misc.ecm_basic) + end + end + + -- this should be rare + if ship_type.equipSlotCapacity.hull_autorepair > 0 and + Engine.rand:Number(1) + 0.75 - size_factor < lawlessness then + ship:AddEquip(e.misc.hull_autorepair) + end +end + +Trader.addCargo = function (ship, direction) + local total = 0 + local empty_space = math.min(ship.freeCapacity, ship:GetEquipFree("cargo")) + local size_factor = empty_space / 20 + local ship_cargo = {} + + local cargoTypes = direction == 'import' and Core.params.imports or Core.params.exports + + if #cargoTypes == 1 then + total = ship:AddEquip(cargoTypes[1], empty_space) + ship_cargo[cargoTypes[1]] = total + elseif #cargoTypes > 1 then + + -- happens if there was very little space left to begin with (eg small + -- ship with lots of equipment). if we let it through then we end up + -- trying to add 0 cargo forever + if size_factor < 1 then + if Core.ships[ship] then + Core.ships[ship]['cargo'] = ship_cargo + end + return 0 + end + + while total < empty_space do + -- get random for direction + local cargo_type = cargoTypes[Engine.rand:Integer(1, #cargoTypes)] + + -- amount based on price and size of ship + local num = math.abs(Game.system:GetCommodityBasePriceAlterations(cargo_type.name)) * size_factor + num = Engine.rand:Integer(num, num * 2) + + local added = ship:AddEquip(cargo_type, num) + if ship_cargo[cargo_type] == nil then + ship_cargo[cargo_type] = added + else + ship_cargo[cargo_type] = ship_cargo[cargo_type] + added + end + total = total + added + end + end + if Core.ships[ship] then + Core.ships[ship]['cargo'] = ship_cargo + end + -- if the table for direction was empty then cargo is empty and total is 0 + return total +end + +Trader.doOrbit = function (ship) + local trader = Core.ships[ship] + local sbody = trader.starport.path:GetSystemBody() + local body = Space.GetBody(sbody.parent.index) + ship:AIEnterLowOrbit(body) + trader['status'] = 'orbit' + Core.log:add(ship, 'Ordering orbit of '..body.label) +end + +local getSystem = function (ship) + local max_range = ship:GetEquip('engine', 1):GetMaximumRange(ship) + max_range = math.min(max_range, 30) + local min_range = max_range / 2; + local systems_in_range = Game.system:GetNearbySystems(min_range) + if #systems_in_range == 0 then + systems_in_range = Game.system:GetNearbySystems(max_range) + end + if #systems_in_range == 0 then + return nil end + if #systems_in_range == 1 then + return systems_in_range[1].path + end + + local target_system = nil + local best_prices = 0 + + -- find best system for cargo + for _, next_system in ipairs(systems_in_range) do + if #next_system:GetStationPaths() > 0 then + local next_prices = 0 + for cargo, count in pairs(ship:GetCargo()) do + next_prices = next_prices + (next_system:GetCommodityBasePriceAlterations(cargo.name) * count) + end + if next_prices > best_prices then + target_system, best_prices = next_system, next_prices + end + end + end + + if target_system == nil then + -- pick a random system as fallback + target_system = systems_in_range[Engine.rand:Integer(1, #systems_in_range)] + + -- get closer systems + local systems_half_range = Game.system:GetNearbySystems(min_range) + + if #systems_half_range > 1 then + target_system = systems_half_range[Engine.rand:Integer(1, #systems_half_range)] + end + end + + -- pick a random starport, if there are any, so the game can simulate + -- travel to it if player arrives after (see Space::DoHyperspaceTo) + local target_starport_paths = target_system:GetStationPaths() + if #target_starport_paths > 0 then + return target_starport_paths[Engine.rand:Integer(1, #target_starport_paths)] + end + + return target_system.path +end + +local jumpToSystem = function (ship, target_path) + if target_path == nil then return nil end + + local status, _, duration = ship:HyperjumpTo(target_path) + + if status ~= 'OK' then + Core.log:add(ship, 'jump status is not OK: ' .. status) + return status + end + + -- update table for ship + Core.ships[ship]['status'] = 'hyperspace_out' + Core.ships[ship]['starport'] = nil + Core.ships[ship]['dest_time'] = Game.time + duration + Core.ships[ship]['jump_time'] = Game.time + Core.ships[ship]['dest_path'] = target_path + Core.ships[ship]['from_path'] = Game.system.path + return status +end + +Trader.getSystemAndJump = function (ship) + -- attention all coders: trade_ships[ship].starport may be nil + if Core.ships[ship].starport then + local body = Space.GetBody(Core.ships[ship].starport.path:GetSystemBody().parent.index) + local port = Core.ships[ship].starport + -- boost away from the starport before jumping if it is too close + if (ship:DistanceTo(port) < 20000) then + ship:AIEnterLowOrbit(body) + end + return jumpToSystem(ship, getSystem(ship)) + end +end + +Trader.emptySpace = function(ship) + return math.min(ship.freeCapacity, ship:GetEquipFree("cargo")) +end + +local function isAtmo(starport) + return starport.type ~= 'STARPORT_ORBITAL' and starport.path:GetSystemBody().parent.hasAtmosphere +end + +local function canAtmo(ship) + return ship:CountEquip(e.misc.atmospheric_shielding) ~= 0 +end + +Trader.isStarportAcceptableForShip = function(starport, ship) + return canAtmo(ship) or not isAtmo(starport) + -- TODO add a check to see if the ship has enough engine power to handle gravity +end + +Trader.getNearestStarport = function(ship, current) + -- get all available routes for this model of ship + local routes = Core.params.local_routes[ship.shipId] + if not routes or #routes == 0 then return nil end + + -- Find the nearest starport that we can land at (other than current) + local starport, distance + for i = 1, #routes do + local next_starport = routes[i].to + if next_starport ~= current then + local next_distance = ship:DistanceTo(next_starport) + if Trader.isStarportAcceptableForShip(next_starport, ship) and ((starport == nil) or (next_distance < distance)) then + starport, distance = next_starport, next_distance + end + end + end + return starport or current +end + +Trader.addFuel = function (ship) + local drive = ship:GetEquip('engine', 1) + + -- a drive must be installed + if not drive then + Core.log:add(ship, 'Ship has no drive!') + return nil + end + + -- the last character of the fitted drive is the class + -- the fuel needed for max range is the square of the drive class + local count = drive.capabilities.hyperclass ^ 2 + + -- account for fuel it already has + count = count - ship:CountEquip(e.cargo.hydrogen) + + local added = ship:AddEquip(e.cargo.hydrogen, count) + + return added +end + +-- TRADER DEFERRED TASKS +-- +-- call the passed function in a specified time, checking whether we are still +-- in this system +Trader.callInThisSystem = function(t, fnc) + local current_path = Game.system.path + Timer:CallAt(t, function() + if Game.system and current_path:IsSameSystem(Game.system.path) then fnc() end + end) +end + +local trader_task = {} +-- a table of functions that can be assigned for delayed execution by the trader +-- { ["fnc1"] = fnc1, ... } +-- made to serialize pending job execution +-- the task function prototype should be: +-- function(ship) + +trader_task.doUndock = function(ship) + -- the player may have left the system or the ship may have already undocked + if ship:exists() and ship:GetDockedWith() then + -- we load goods before departure + Trader.addCargo(ship, 'export') + if not ship:Undock() then + -- unable to undock, try again later + Trader.assignTask(ship, Game.time + Core.WAIT_FOR_NEXT_UNDOCK, 'doUndock') + return true + end + end +end + +trader_task.doRedock = function(ship) + trader = Core.ships[ship] + if trader then + if ship:exists() and ship.flightState ~= 'HYPERSPACE' then + trader['starport'] = Trader.getNearestStarport(ship, trader.starport) + ship:AIDockWith(trader.starport) + trader['status'] = 'inbound' + trader.ts_error = "dock_aft_6h" + end + end +end + +-- at the appointed time the specified function will be executed, in addition, +-- it will be executed only in the same system in which it is assigned, and +-- also the assignment can be serialized +-- ship: object +-- delay: absolute game time, when to execute the task +-- fn_name: string, one of the keys in the table 'trader_task' +Trader.assignTask = function(ship, delay, fn_name) + local trader = Core.ships[ship] + if trader then + trader.delay = delay + trader.fnc = fn_name + Trader.callInThisSystem(delay, function() + trader.delay = nil + trader.fnc = nil + trader_task[fn_name](ship) + end) + end +end + +Trader.spawnInCloud = function(ship_name, cloud, route, dest_time) + -- choose random local route for choosen ship, + -- the hyperjump target depends on this (for multiple system) + ship = Space.SpawnShip(ship_name, 0, 0, {cloud.from, route.from:GetSystemBody().path, dest_time}) + ship:SetLabel(Ship.MakeRandomLabel()) + Trader.addEquip(ship) + Trader.addFuel(ship) + Trader.addCargo(ship, 'import') + Core.ships[ship] = { + status = 'hyperspace', + dest_time = dest_time, + dest_path = Game.system.path, + from_path = cloud.from, + ship_name = ship_name, + route = route + } +end + +return Trader diff --git a/data/pigui/libs/array-table.lua b/data/pigui/libs/array-table.lua new file mode 100644 index 000000000..3bb871edc --- /dev/null +++ b/data/pigui/libs/array-table.lua @@ -0,0 +1,226 @@ +local ui = require 'pigui.baseui' +array_table = {} + +-- +-- Table: array_table +-- + +-- +-- Function: array_table.draw +-- +-- Draw a table based on the contents of the "tbl" array +-- +-- > array_table.draw(id, tbl, iter, column, extra) +-- +-- Parameters: +-- +-- id - unique string identifier +-- +-- tbl - an array of hashtables with the same keys - main data source +-- +-- iter - pairs, ipairs, e.t.c. +-- +-- columns - array of hashtables, determining the presence and order of columns. keys: +-- name - column name +-- key - key name from table +-- fnc - function to convert value to string +-- string - if true, the given column is sorted as a string, false - as number +-- +-- extra +-- totals - table similar to tbl, its contents are shown at the very botton, under the separator +-- callbacks +-- onClick - this function is called if the line is clicked +-- isSelected - this function is called for every row and if it returns true the row is highlighed +-- +-- Example: +-- +-- > table_array.draw("hyperspace_params", params, ipairs, +-- > { +-- > { name = "From", key = "from", fnc = sysName, string = true }, +-- > { name = "Distance", key = "distance", fnc = format("%.2f l.y.") }, +-- > { name = "Fuel", key = "fuel", fnc = format("%.2f t") }, +-- > { name = "Duration", key = "duration", fnc = ui.Format.Duration }, +-- > { name = "Cloud Duration", key = "cloud_duration", fnc = ui.Format.Duration } +-- > },{ +-- > callbacks = { +-- > onClick = function(row) +-- > Game.sectorView:SwitchToPath(row.from) +-- > end, +-- > isSelected = function(row) +-- > return row.from:IsSameSystem(selected_in_sectorview) +-- > end +-- > }}) + +local sort_param = {} -- we need to keep sorting options between frames + +array_table.draw = function(id, tbl, iter, columns, extra) + local callbacks = extra and extra.callbacks + local data = {} + -- sort so that nil always goes down the table + local function less (a, b) return a and ( not b or a < b ) end + local function greater(a, b) return a and ( not b or b < a ) end + local sort_symbol = { + [less] = { sym = "▼ ", nxt = greater }, + [greater] = { sym = "▲ ", nxt = less } + } + if not sort_param[id] then sort_param[id] = {} end + + -- build table + for i, row0 in iter(tbl) do + local row = {} + for j,column_cfg in ipairs(columns) do + if column_cfg.key == "#" then + row[j] = i + else + row[j] = row0[column_cfg.key] + end + end + -- remember the original row for callbacks + row[#columns+1] = row0 + table.insert(data, row) + end + local widths = {} + for i = 1, #columns do widths[i] = 0 end + + -- draw table + -- top line + ui.columns(#columns, id, false) + for i, column_cfg in ipairs(columns) do + if sort_param[id][column_cfg.key] then + local key = column_cfg.key + local fnc = sort_param[id][key] + if column_cfg.string then + local string_fnc = column_cfg.fnc or tostring + table.sort(data, function(a, b) + return fnc(a[i] and string_fnc(a[i]), b[i] and string_fnc(b[i])) + end) + else + table.sort(data, function(a, b) return fnc(a[i], b[i]) end) + end + if ui.selectable(sort_symbol[fnc].sym .. column_cfg.name .. "##" .. id, false, {}) then + sort_param[id] = { [column_cfg.key] = sort_symbol[fnc].nxt } + end + else + if ui.selectable(column_cfg.name .. "##" .. id, false, {}) then + sort_param[id] = { [column_cfg.key] = less } + end + end + widths[i] = math.max(widths[i], ui.calcTextSize(sort_symbol[less].sym .. column_cfg.name .. "--").x) + ui.nextColumn() + end + ui.separator() + -- other lines + local highlight_box + local selected_boxes = {} + local top_left, down_right = 0, 0 + -- draw next cell and recalculate column width + local function putCell(item, i, fnc) + if not fnc then fnc = tostring end + local txt = item and fnc(item) or "-" + ui.text(txt) + widths[i] = math.max(widths[i], ui.calcTextSize(txt .. "--").x) + end + if #data > 0 then + for _,item in ipairs(data) do + top_left = ui.getCursorScreenPos() + for i,_ in ipairs(columns) do + putCell(item[i], i, columns[i].fnc) + down_right = ui.getCursorScreenPos() + down_right.x = down_right.x + ui.getColumnWidth() + ui.nextColumn() + end + down_right.y = ui.getCursorScreenPos().y - 2 + top_left.y = top_left.y - 2 + if callbacks and callbacks.isSelected and callbacks.isSelected(item[#columns + 1]) then + table.insert(selected_boxes, { top_left, down_right }) + end + if not highlight_box and ui.isWindowHovered() and ui.isMouseHoveringRect(top_left, down_right, false) then + highlight_box = { top_left, down_right } + if ui.isMouseClicked(0) and callbacks and callbacks.onClick then + callbacks.onClick( item[#columns + 1] ) -- in the additional column we have saved the original row + end + end + end + end + -- totals + if extra and extra.totals then + ui.separator() + for _, row in ipairs(extra.totals) do + for i,column_cfg in ipairs(columns) do + putCell(row[column_cfg.key], i, column_cfg.fnc) + ui.nextColumn() + end + end + end + -- set column width + for i = 1,#widths do ui.setColumnWidth(i-1, widths[i]) end + -- end columns + ui.columns(1, "") + -- draw highlight + if highlight_box then + ui.addRectFilled(highlight_box[1], highlight_box[2], ui.theme.colors.white:opacity(0.3), 0, 0) + end + if #selected_boxes ~= 0 then + for _, selected_box in ipairs(selected_boxes) do + ui.addRectFilled(selected_box[1], selected_box[2], ui.theme.colors.white:opacity(0.2), 0, 0) + end + end +end + +-- +-- Function: array_table.addkeys +-- +-- create stateless iterator to create "virtual" keys on the fly +-- the k,v from iterator is passed to function, and the value that it returns is written to given key +-- +-- > myiter = addKeys(iter, config) +-- +-- Parameters: +-- +-- iter - pairs, ipairs e.t.c. +-- +-- config - hashtable: +-- key1 = fnc1(k,v) ... return value1 end +-- key2 = fnc2(k,v) ... return value2 end +-- ... +-- +-- Return: +-- +--- Example: +-- +-- > local a = { {one = 10, two = 20}, +-- > {one = 15, two = 18} } +-- > local addSum = addKeys(ipairs, { sum = function(k,v) return v.one + v.two end } ) +-- > +-- > for k, v in addSum(a) do +-- > print(k, v.one, v.two, v.sum) +-- > end +-- > +-- > 1, 10, 20, 30 +-- > 2, 15, 18, 33 +-- +array_table.addKeys = function(iter, config) + return function(tbl) + local step, context, position = iter(tbl) + return function(a, i) + local row0 + i, row0 = step(a, i) + if row0 then + local row = {} + if type(row0) == 'table' then + for key, value in pairs(row0) do + row[key] = value + end + else + row[1] = row0 + end + for key, fnc in pairs(config) do + row[key] = fnc(i, row0) + end + return i, row + end + end, context, position + end +end + +return array_table diff --git a/data/pigui/libs/forwarded.lua b/data/pigui/libs/forwarded.lua index 0eef8d79a..268321bd9 100644 --- a/data/pigui/libs/forwarded.lua +++ b/data/pigui/libs/forwarded.lua @@ -73,6 +73,8 @@ ui.getProjectedBodiesGrouped = pigui.GetProjectedBodiesGrouped ui.isMouseReleased = pigui.IsMouseReleased ui.isMouseHoveringRect = pigui.IsMouseHoveringRect ui.collapsingHeader = pigui.CollapsingHeader +ui.treeNode = pigui.TreeNode +ui.treePop = pigui.TreePop ui.beginPopupModal = pigui.BeginPopupModal ui.endPopup = pigui.EndPopup ui.openPopup = pigui.OpenPopup diff --git a/data/pigui/libs/text.lua b/data/pigui/libs/text.lua index 47d4f40bc..17d5a5323 100644 --- a/data/pigui/libs/text.lua +++ b/data/pigui/libs/text.lua @@ -175,7 +175,7 @@ ui.Format = { local s = number < 0.0 and "-" or "" number = math.abs(number) local fmt = "%." .. (places or '2') .. "f%s" - if number < 1e3 then return s .. number + if number < 1e3 then return s .. fmt:format(number, "") elseif number < 1e6 then return s .. math.floor(number / 1e3) .. "," .. number % 1e3 elseif number < 1e9 then return s .. fmt:format(number / 1e6, "mil") elseif number < 1e12 then return s .. fmt:format(number / 1e9, "bil") diff --git a/src/Body.cpp b/src/Body.cpp index e7c046bca..32d392408 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -234,7 +234,7 @@ void Body::UpdateFrame() FrameId parent = frame->GetParent(); Frame *newFrame = Frame::GetFrame(parent); if (newFrame) { // don't fall out of root frame - Output("%s leaves frame %s\n", GetLabel().c_str(), frame->GetLabel().c_str()); + Log::Verbose("{} leaves frame{}\n", GetLabel(), frame->GetLabel()); SwitchToFrame(parent); return; } @@ -246,7 +246,7 @@ void Body::UpdateFrame() const vector3d pos = GetPositionRelTo(kid); if (pos.Length() >= kid_frame->GetRadius()) continue; SwitchToFrame(kid); - Output("%s enters frame %s\n", GetLabel().c_str(), kid_frame->GetLabel().c_str()); + Log::Verbose("{} enters frame{}\n", GetLabel(), frame->GetLabel()); break; } } diff --git a/src/Game.cpp b/src/Game.cpp index 46ce98d4a..6823beb2b 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -289,14 +289,6 @@ void Game::TimeStep(float step) if (Pi::game->GetTime() >= m_hyperspaceEndTime) { SwitchToNormalSpace(); m_player->EnterSystem(); - // event "onEnterSystem(player)" is in the event queue after p_player->EnterSystem() - // in this callback, for example, fuel is reduced - // but event handling has already passed, and will only be in the next frame - // it turns out that we are already in the system, but the fuel has not yet been reduced - // and then the drawing of the views will go, and inconsistencies may occur, - // for example, the sphere of the hyperjump range will be too large - // so we forcefully call event handling again - LuaEvent::Emit(); RequestTimeAccel(TIMEACCEL_1X); } else m_hyperspaceProgress += step; diff --git a/src/lua/LuaObject.h b/src/lua/LuaObject.h index 7fe764d07..c51f5bfa8 100644 --- a/src/lua/LuaObject.h +++ b/src/lua/LuaObject.h @@ -32,10 +32,15 @@ // StarSystem *s = Pi::GetGalaxy()->GetStarSystem(SystemPath(0,0,0,0)); // LuaObject::PushToLua(s); // -// // Heap-allocated, Lua will get a copy +// // Stack-allocated, Lua will get a copy // SystemPath path(0,0,0,0,1); // LuaObject::PushToLua(path); // +// // Create an object, fully owned by lua +// // WARNING! the lifetime of an object will be determined by the lua garbage +// // collector, so a pointer to it should not be stored in any form on the C++ side +// LuaObject::CreateInLua(ship_id); // constructor arguments are passed +// // Get an object from the Lua stack at index n. Causes a Lua exception if the // object doesn't exist or the types don't match. // @@ -208,6 +213,8 @@ public: static inline void PushToLua(DeleteEmitter *o); // LuaCoreObject static inline void PushToLua(RefCounted *o); // LuaSharedObject static inline void PushToLua(const T &o); // LuaCopyObject + template + static inline void CreateInLua(Args &&... args); template static inline Ret CallMethod(T *o, const Key &key, const Args &... args); @@ -354,6 +361,30 @@ private: LuaRef m_ref; }; +// wrapper for a "lua-owned" object. +// the wrapper is deleted by lua gc, and when it is deleted, it also deletes the wrapped object +template +class LuaOwnObject : public LuaObject { +public: + LuaOwnObject(T *o) + { + m_object = o; + } + + ~LuaOwnObject() + { + delete (m_object); + } + + LuaWrappable *GetObject() const + { + return m_object; + } + +private: + T *m_object; +}; + // push methods, create wrappers if necessary // wrappers are allocated from Lua memory template @@ -376,6 +407,14 @@ inline void LuaObject::PushToLua(const T &o) Register(new (LuaObjectBase::Allocate(sizeof(LuaCopyObject))) LuaCopyObject(o)); } +template +template +inline void LuaObject::CreateInLua(Args &&... args) +{ + T *p(new T(std::forward(args)...)); + Register(new (LuaObjectBase::Allocate(sizeof(LuaOwnObject))) LuaOwnObject(static_cast(p))); +} + template template inline Ret LuaObject::CallMethod(T *o, const Key &key, const Args &... args) diff --git a/src/lua/LuaPiGui.cpp b/src/lua/LuaPiGui.cpp index 06ab457d9..21dfa4972 100644 --- a/src/lua/LuaPiGui.cpp +++ b/src/lua/LuaPiGui.cpp @@ -1863,7 +1863,7 @@ static int l_pigui_get_projected_bodies_grouped(lua_State *l) for (Body *body : Pi::game->GetSpace()->GetBodies()) { if (body == Pi::game->GetPlayer()) continue; if (body->GetType() == ObjectType::PROJECTILE) continue; - if ((body->GetType() == ObjectType::SHIP || body->GetType() == ObjectType::CARGOBODY) && + if ((body->GetType() == ObjectType::SHIP || body->GetType() == ObjectType::CARGOBODY || body->GetType() == ObjectType::HYPERSPACECLOUD) && body->GetPositionRelTo(Pi::player).Length() > ship_max_distance) continue; const PiGui::TScreenSpace res = lua_world_space_to_screen_space(body); // defined in LuaPiGui.cpp if (!res._onScreen) continue; @@ -2653,6 +2653,75 @@ static int l_pigui_collapsing_header(lua_State *l) return 1; } +/* + * Function: treeNode + * + * Start drawing a tree node + * + * > opened = ui.treeNode(label, flags) + * + * Example: + * + * > if ui.treeNode("things", {"DefaultOpen"}) then + * > ... + * > if ui.treeNode("subthings") then + * > ... + * > ui.treePop() + * > end + * > ... + * > ui.treePop() + * > end + * + * Parameters: + * + * label - string, headline + * flag - + * + * Returns: + * + * opened - bool, treenode opens when you click no the triangle at the beginning of the line + * + */ +static int l_pigui_treenode(lua_State *l) +{ + PROFILE_SCOPED() + std::string label = LuaPull(l, 1); + ImGuiTreeNodeFlags flags = LuaPull(l, 2, ImGuiTreeNodeFlags_None); + LuaPush(l, ImGui::TreeNodeEx(label.c_str(), flags)); + return 1; +} + +/* + * Function: treePop + * + * End drawing a tree node + * + * > ui.treePop() + * + * Example: + * + * > if ui.treeNode("things", {"DefaultOpen"}) then + * > ... + * > if ui.treeNode("subthings") then + * > ... + * > ui.treePop() + * > end + * > ... + * > ui.treePop() + * > end + * + * Returns: + * + * nothing + * + */ +static int l_pigui_treepop(lua_State *l) +{ + PROFILE_SCOPED() + ImGui::TreePop(); + return 0; +} + static int l_pigui_push_text_wrap_pos(lua_State *l) { PROFILE_SCOPED() @@ -2831,6 +2900,8 @@ void LuaObject::RegisterClass() { "Combo", l_pigui_combo }, { "ListBox", l_pigui_listbox }, { "CollapsingHeader", l_pigui_collapsing_header }, + { "TreeNode", l_pigui_treenode }, + { "TreePop", l_pigui_treepop }, { "CaptureMouseFromApp", l_pigui_capture_mouse_from_app }, { "PlotHistogram", l_pigui_plot_histogram }, { "ProgressBar", l_pigui_progress_bar }, diff --git a/src/lua/LuaShip.cpp b/src/lua/LuaShip.cpp index b28cd15a8..096f80764 100644 --- a/src/lua/LuaShip.cpp +++ b/src/lua/LuaShip.cpp @@ -16,6 +16,8 @@ #include "Space.h" #include "SpaceStation.h" #include "ship/PlayerShipController.h" +#include "ship/PrecalcPath.h" +#include "src/lua.h" /* * Class: Ship @@ -660,6 +662,42 @@ static int l_ship_is_ecm_ready(lua_State *l) return 1; } +/* + * Method: GetDurationForDistance + * + * Calculating the duration of the flight of a given ship at a specified distance. + * Assumed that at the beginning and at the end of the travel the speed is 0. + * + * > duration = ship:GetDurationForDistance(distance) + * + * Parameters: + * + * distance - length, in meters + * + * Result: + * + * duration - travel time, in seconds. + * + */ +static int l_ship_get_duration_for_distance(lua_State *l) +{ + Ship *ship = LuaObject::CheckFromLua(1); + double distance = LuaPull(l, 2); + const ShipType *st = ship->GetShipType(); + const shipstats_t ss = ship->GetStats(); + PrecalcPath pp( + distance, // distance + 0.0, // velocity at start + st->effectiveExhaustVelocity, + st->linThrust[THRUSTER_FORWARD], + st->linAccelerationCap[THRUSTER_FORWARD], + 1000 * (ss.static_mass + ss.fuel_tank_mass_left), // 100% mass of the ship + 1000 * ss.fuel_tank_mass_left * 0.8, // multipied to 0.8 have fuel reserve + 0.85); // braking margin + LuaPush(l, pp.getFullTime()); + return 1; +} + /* * Method: InitiateHyperjumpTo * @@ -751,6 +789,30 @@ static int l_ship_abort_hyperjump(lua_State *l) return 0; } +/* + * Method: Create + * + * Create a new ship object by type id (string) + * The ship is not added to space. + * The object is completely controlled by lua. + * + * > ship = ship:Create(ship_id) + * + * Parameters: + * + * ship_id - The internal id of the ship type. + * + * Result: + * + * ship - new ship object + */ +static int l_ship_create(lua_State *l) +{ + auto name = LuaPull(l, 1); + LuaObject::CreateInLua(name); + return 1; +} + /* * Method: GetInvulnerable * @@ -1474,7 +1536,8 @@ static int l_ship_get_current_ai_command(lua_State *l) LuaPush(l, EnumStrings::GetString("ShipAICmdName", cmd->GetType())); return 1; } else { - return 0; + lua_pushnil(l); + return 1; } } @@ -1563,6 +1626,7 @@ void LuaObject::RegisterClass() { "UseECM", l_ship_use_ecm }, { "IsECMReady", l_ship_is_ecm_ready }, + { "GetDurationForDistance", l_ship_get_duration_for_distance }, { "GetDockedWith", l_ship_get_docked_with }, { "Undock", l_ship_undock }, @@ -1582,6 +1646,8 @@ void LuaObject::RegisterClass() { "InitiateHyperjumpTo", l_ship_initiate_hyperjump_to }, { "AbortHyperjump", l_ship_abort_hyperjump }, + { "Create", l_ship_create }, + { "GetInvulnerable", l_ship_get_invulnerable }, { "SetInvulnerable", l_ship_set_invulnerable }, diff --git a/src/ship/PrecalcPath.cpp b/src/ship/PrecalcPath.cpp index e0b949e31..b4716476c 100644 --- a/src/ship/PrecalcPath.cpp +++ b/src/ship/PrecalcPath.cpp @@ -242,7 +242,7 @@ static double t1_from_S1_mixed(val S1, val V0, val EV, val m, val F, val acap, } } -#define PRECALCPATH_TESTMODE +//#define PRECALCPATH_TESTMODE #ifdef PRECALCPATH_TESTMODE enum class TestMode { CONSTRUCTOR,