1-- Copyright © 2008-2021 Pioneer Developers. See AUTHORS.txt for details
2-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt
3
4local utils = require 'utils'
5local Serializer = require 'Serializer'
6local Lang = require 'Lang'
7local ShipDef = require 'ShipDef'
8local Timer = require 'Timer'
9local Comms = require 'Comms'
10
11local Game = package.core['Game']
12local Space = package.core['Space']
13
14-- XXX this is kind of hacky, but we'll put up with it for now
15-- Ideally we should separate out the hyperdrives into their own module
16-- that can function independently of the cargo
17local cargo = {}
18local laser = {}
19local hyperspace = {}
20local misc = {}
21
22--
23-- Class: EquipType
24--
25-- A container for a ship's equipment.
26--
27-- Its constructor takes a table, the "specs". Mandatory fields are the following:
28--  * l10n_key: the key to look up the name and description of
29--          the object in a language-agnostic way
30--  * l10n_resource: where to look up the aforementioned key. If not specified,
31--          the system assumes "equipment-core"
32--  * capabilities: a table of string->int, having at least "mass" as a valid key
33--
34-- All specs are copied directly within the object (even those I know nothing about),
35-- but it is a shallow copy. This is particularly important for the capabilities, as
36-- modifying the capabilities of one EquipType instance might modify them for other
37-- instances if the same table was used for all (which is strongly discouraged by the
38-- author, but who knows ? Some people might find it useful.)
39--
40--
41local EquipType = utils.inherits(nil, "EquipType")
42
43function EquipType.New (specs)
44	local obj = {}
45	for i,v in pairs(specs) do
46		obj[i] = v
47	end
48	if not obj.l10n_resource then
49		obj.l10n_resource = "equipment-core"
50	end
51	local l = Lang.GetResource(obj.l10n_resource)
52	obj.volatile = {
53		description = l:get(obj.l10n_key.."_DESCRIPTION") or "",
54		name = l[obj.l10n_key] or ""
55	}
56	setmetatable(obj, EquipType.meta)
57	if type(obj.slots) ~= "table" then
58		obj.slots = {obj.slots}
59	end
60	return obj
61end
62
63function EquipType:Serialize()
64	local tmp = EquipType.Super().Serialize(self)
65	local ret = {}
66	for k,v in pairs(tmp) do
67		if type(v) ~= "function" then
68			ret[k] = v
69		end
70	end
71
72	ret.volatile = nil
73	return ret
74end
75
76function EquipType.Unserialize(data)
77	local obj = EquipType.Super().Unserialize(data)
78	setmetatable(obj, EquipType.meta)
79	if not obj.l10n_resource then
80		obj.l10n_resource = "equipment-core"
81	end
82	local l = Lang.GetResource(obj.l10n_resource)
83	obj.volatile = {
84		description = l:get(obj.l10n_key.."_DESCRIPTION") or "",
85		name = l[obj.l10n_key] or ""
86	}
87	return obj
88end
89
90--
91-- Group: Methods
92--
93
94--
95-- Method: GetDefaultSlot
96--
97--  returns the default slot for this equipment
98--
99-- Parameters:
100--
101--  ship (optional) - if provided, tailors the answer for this specific ship
102--
103-- Return:
104--
105--  slot_name - A string identifying the slot.
106--
107function EquipType:GetDefaultSlot(ship)
108	return self.slots[1]
109end
110
111--
112-- Method: IsValidSlot
113--
114--  tells whether the given slot is valid for this equipment
115--
116-- Parameters:
117--
118--  slot - a string identifying the slot in question
119--
120--  ship (optional) - if provided, tailors the answer for this specific ship
121--
122-- Return:
123--
124--  valid - a boolean qualifying the validity of the slot.
125--
126function EquipType:IsValidSlot(slot, ship)
127	for _, s in ipairs(self.slots) do
128		if s == slot then
129			return true
130		end
131	end
132	return false
133end
134
135function EquipType:GetName()
136	return self.volatile.name
137end
138
139function EquipType:GetDescription()
140	return self.volatile.description
141end
142
143local function __ApplyMassLimit(ship, capabilities, num)
144	if num <= 0 then return 0 end
145	-- we need to use mass_cap directly (not, eg, ship.freeCapacity),
146	-- because ship.freeCapacity may not have been updated when Install is called
147	-- (see implementation of EquipSet:Set)
148	local avail_mass = ShipDef[ship.shipId].capacity - (ship.mass_cap or 0)
149	local item_mass = capabilities.mass or 0
150	if item_mass > 0 then
151		num = math.min(num, math.floor(avail_mass / item_mass))
152	end
153	return num
154end
155
156local function __ApplyCapabilities(ship, capabilities, num, factor)
157	if num <= 0 then return 0 end
158	factor = factor or 1
159	for k,v in pairs(capabilities) do
160		local full_name = k.."_cap"
161		local prev = (ship:hasprop(full_name) and ship[full_name]) or 0
162		ship:setprop(full_name, (factor*v*num)+prev)
163	end
164	return num
165end
166
167function EquipType:Install(ship, num, slot)
168	local caps = self.capabilities
169	num = __ApplyMassLimit(ship, caps, num)
170	return __ApplyCapabilities(ship, caps, num, 1)
171end
172
173function EquipType:Uninstall(ship, num, slot)
174	return __ApplyCapabilities(ship, self.capabilities, num, -1)
175end
176
177-- Base type for weapons
178local LaserType = utils.inherits(EquipType, "LaserType")
179function LaserType:Install(ship, num, slot)
180	if num > 1 then num = 1 end -- FIXME: support installing multiple lasers (e.g., in the "cargo" slot?)
181	if LaserType.Super().Install(self, ship, 1, slot) < 1 then return 0 end
182	local prefix = slot..'_'
183	for k,v in pairs(self.laser_stats) do
184		ship:setprop(prefix..k, v)
185	end
186	return 1
187end
188
189function LaserType:Uninstall(ship, num, slot)
190	if num > 1 then num = 1 end -- FIXME: support uninstalling multiple lasers (e.g., in the "cargo" slot?)
191	if LaserType.Super().Uninstall(self, ship, 1) < 1 then return 0 end
192	local prefix = (slot or "laser_front").."_"
193	for k,v in pairs(self.laser_stats) do
194		ship:unsetprop(prefix..k)
195	end
196	return 1
197end
198
199-- Single drive type, no support for slave drives.
200local HyperdriveType = utils.inherits(EquipType, "HyperdriveType")
201
202function HyperdriveType:GetMaximumRange(ship)
203	return 625.0*(self.capabilities.hyperclass ^ 2) / (ship.staticMass + ship.fuelMassLeft)
204end
205
206-- range_max is as usual optional
207function HyperdriveType:GetDuration(ship, distance, range_max)
208	range_max = range_max or self:GetMaximumRange(ship)
209	local hyperclass = self.capabilities.hyperclass
210	return 0.36*distance^2/(range_max*hyperclass) * (86400*math.sqrt(ship.staticMass + ship.fuelMassLeft))
211end
212
213-- range_max is optional, distance defaults to the maximal range.
214function HyperdriveType:GetFuelUse(ship, distance, range_max)
215	range_max = range_max or self:GetMaximumRange(ship)
216	local distance = distance or range_max
217	local hyperclass_squared = self.capabilities.hyperclass^2
218	return math.clamp(math.ceil(hyperclass_squared*distance / range_max), 1, hyperclass_squared);
219end
220
221-- if the destination is reachable, returns: distance, fuel, duration
222-- if the destination is out of range, returns: distance
223-- if the specified jump is invalid, returns nil
224function HyperdriveType:CheckJump(ship, source, destination)
225	if ship:GetEquip('engine', 1) ~= self or source:IsSameSystem(destination) then
226		return nil
227	end
228	local distance = source:DistanceTo(destination)
229	local max_range = self:GetMaximumRange(ship) -- takes fuel into account
230	if distance > max_range then
231		return distance
232	end
233	local fuel = self:GetFuelUse(ship, distance, max_range) -- specify range_max to avoid unnecessary recomputing.
234
235	local duration = self:GetDuration(ship, distance, max_range) -- same as above
236	return distance, fuel, duration
237end
238
239-- like HyperdriveType.CheckJump, but uses Game.system as the source system
240-- if the destination is reachable, returns: distance, fuel, duration
241-- if the destination is out of range, returns: distance
242-- if the specified jump is invalid, returns nil
243function HyperdriveType:CheckDestination(ship, destination)
244	if not Game.system then
245		return nil
246	end
247	return self:CheckJump(ship, Game.system.path, destination)
248end
249
250-- Give the range for the given remaining fuel
251-- If the fuel isn't specified, it takes the current value.
252function HyperdriveType:GetRange(ship, remaining_fuel)
253	local range_max = self:GetMaximumRange(ship)
254	local fuel_max = self:GetFuelUse(ship, range_max, range_max)
255	remaining_fuel = remaining_fuel or ship:CountEquip(self.fuel)
256
257	if fuel_max <= remaining_fuel then
258		return range_max, range_max
259	end
260	local range = range_max*remaining_fuel/fuel_max
261
262	while range > 0 and self:GetFuelUse(ship, range, range_max) > remaining_fuel do
263		range = range - 0.05
264	end
265
266	-- range is never negative
267	range = math.max(range, 0)
268	return range, range_max
269end
270
271local HYPERDRIVE_SOUNDS_NORMAL = {
272	warmup = "Hyperdrive_Charge",
273	abort = "Hyperdrive_Abort",
274	jump = "Hyperdrive_Jump",
275}
276
277local HYPERDRIVE_SOUNDS_MILITARY = {
278	warmup = "Hyperdrive_Charge_Military",
279	abort = "Hyperdrive_Abort_Military",
280	jump = "Hyperdrive_Jump_Military",
281}
282
283function HyperdriveType:HyperjumpTo(ship, destination)
284	-- First off, check that this is the primary engine.
285	local engines = ship:GetEquip('engine')
286	local primary_index = 0
287	for i,e in ipairs(engines) do
288		if e == self then
289			primary_index = i
290			break
291		end
292	end
293	if primary_index == 0 then
294		-- wrong ship
295		return "WRONG_SHIP"
296	end
297	local distance, fuel_use, duration = self:CheckDestination(ship, destination)
298	if not distance then
299		return "OUT_OF_RANGE"
300	end
301	if not fuel_use then
302		return "INSUFFICIENT_FUEL"
303	end
304	ship:setprop('nextJumpFuelUse', fuel_use)
305	local warmup_time = 5 + self.capabilities.hyperclass*1.5
306
307	local sounds
308	if self.fuel == cargo.military_fuel then
309		sounds = HYPERDRIVE_SOUNDS_MILITARY
310	else
311		sounds = HYPERDRIVE_SOUNDS_NORMAL
312	end
313
314	return ship:InitiateHyperjumpTo(destination, warmup_time, duration, sounds), fuel_use, duration
315end
316
317function HyperdriveType:OnLeaveHyperspace(ship)
318	if ship:hasprop('nextJumpFuelUse') then
319		local amount = ship.nextJumpFuelUse
320		ship:RemoveEquip(self.fuel, amount)
321		if self.byproduct then
322			ship:AddEquip(self.byproduct, amount)
323		end
324		ship:unsetprop('nextJumpFuelUse')
325	end
326end
327
328local SensorType = utils.inherits(EquipType, "SensorType")
329
330function SensorType:BeginAcquisition(callback)
331	self:ClearAcquisition()
332	self.callback = callback
333	if self:OnBeginAcquisition() then
334		self.state = "RUNNING"
335		self.stop_timer = false
336		Timer:CallEvery(1, function()
337			return self:ScanProgress()
338		end)
339	end
340	self:DoCallBack()
341end
342
343function SensorType:ScanProgress()
344	if self.stop_timer == true then
345		return true
346	end
347	if self:IsScanning() then
348		self:OnProgress()
349		if self:IsScanning() then
350			self.stop_timer = false
351		end
352	elseif self.state == "PAUSED" then
353		self.stop_timer = false
354	elseif self.state == "DONE" then
355		self.stop_timer = true
356	end
357	self:DoCallBack()
358	return self.stop_timer
359end
360
361function SensorType:PauseAcquisition()
362	if self:IsScanning() then
363		self.state = "PAUSED"
364	end
365	self:DoCallBack()
366end
367
368function SensorType:UnPauseAcquisition()
369	if self.state == "PAUSED" then
370		self.state = "RUNNING"
371	end
372	self:DoCallBack()
373end
374
375function SensorType:ClearAcquisition()
376	self:OnClear()
377	self.state = "DONE"
378	self.stop_timer = true
379	self:DoCallBack()
380	self.callback = nil
381end
382
383function SensorType:GetLastResults()
384	return self.progress
385end
386
387-- gets called from C++ to set the MeterBar value
388-- must return a number
389function SensorType:GetProgress()
390	if type(self.progress) == "number" then
391		return self.progress
392	else
393		return 0
394	end
395end
396
397function SensorType:IsScanning()
398	return self.state == "RUNNING" or self.state == "HALTED"
399end
400
401function SensorType:DoCallBack()
402	if self.callback then self.callback(self.progress, self.state) end
403end
404
405local BodyScannerType = utils.inherits(SensorType, "BodyScannerType")
406
407function BodyScannerType:OnBeginAcquisition()
408	local closest_planet = Game.player:FindNearestTo("PLANET")
409	if closest_planet then
410		local altitude = self:DistanceToSurface(closest_planet)
411		if altitude and altitude < self.max_range then
412			self.target_altitude = altitude
413			self.target_body_path = closest_planet.path
414			local l = Lang.GetResource(self.l10n_resource)
415			Comms.Message(l.STARTING_SCAN.." "..string.format('%6.3f km',self.target_altitude/1000))
416			return true
417		end
418	end
419	return false
420end
421
422function BodyScannerType:OnProgress()
423	local l = Lang.GetResource(self.l10n_resource)
424	local target_body = Space.GetBody(self.target_body_path.bodyIndex)
425	if target_body and target_body:exists() then
426		local altitude = self:DistanceToSurface(target_body)
427		local distance_diff = math.abs(altitude - self.target_altitude)
428		local percentual_diff = distance_diff/self.target_altitude
429		if percentual_diff <= self.bodyscanner_stats.scan_tolerance then
430			if self.state == "HALTED" then
431				Comms.Message(l.SCAN_RESUMED)
432				self.state = "RUNNING"
433			end
434			self.progress = self.progress + self.bodyscanner_stats.scan_speed
435			if self.progress > 100 then
436				self.state = "DONE"
437				self.progress = {body=target_body.path, altitude=self.target_altitude}
438				Comms.Message(l.SCAN_COMPLETED)
439			end
440		else -- strayed out off range
441			if self.state == "RUNNING" then
442				local lower_limit = self.target_altitude-(percentual_diff*self.target_altitude)
443				local upper_limit = self.target_altitude+(percentual_diff*self.target_altitude)
444				Comms.Message(l.OUT_OF_SCANRANGE.." "..string.format('%6.3f km',lower_limit/1000).." - "..string.format('%6.3f km',upper_limit/1000))
445			end
446			self.state = "HALTED"
447		end
448	else -- we lost the target body
449		self:ClearAcquisition()
450	end
451end
452
453function BodyScannerType:OnClear()
454	self.target_altitude = 0
455	self.progress = 0
456end
457
458function BodyScannerType:DistanceToSurface(body)
459	return  select(3,Game.player:GetGroundPosition(body)) -- altitude
460end
461
462Serializer:RegisterClass("LaserType", LaserType)
463Serializer:RegisterClass("EquipType", EquipType)
464Serializer:RegisterClass("HyperdriveType", HyperdriveType)
465Serializer:RegisterClass("SensorType", SensorType)
466Serializer:RegisterClass("BodyScannerType", BodyScannerType)
467
468return {
469	cargo			= cargo,
470	laser			= laser,
471	hyperspace		= hyperspace,
472	misc			= misc,
473	EquipType		= EquipType,
474	LaserType		= LaserType,
475	HyperdriveType	= HyperdriveType,
476	SensorType		= SensorType,
477	BodyScannerType	= BodyScannerType
478}
479