From 5de6ee86b888f475d1e6b63353e7bb2d287b325e Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 22:15:07 -0500 Subject: [PATCH 01/15] utils: add utils.keys() to iterate over the keys of a table --- data/libs/utils.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/data/libs/utils.lua b/data/libs/utils.lua index cbe6e9964e..5782773f9b 100644 --- a/data/libs/utils.lua +++ b/data/libs/utils.lua @@ -11,6 +11,24 @@ local Engine = require 'Engine' -- rand -- local utils = {} +-- +-- Function: keys +-- +-- Create an iterator that returns a numberic index and the keys of the +-- provided table. Iteration order is undefined (uses pairs() internally). +-- +-- Example: +-- > for i, k in utils.keys(table) do ... end +-- +function utils.keys(table) + local k = nil + local f = function(s, i) + k = next(s, k) + return (k and i + 1 or nil), k + end + return f, table, 0 +end + -- -- Function: numbered_keys -- From fcb80d979757cf6e94975f4a6cc74b87754fbfd7 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 22:14:46 -0500 Subject: [PATCH 02/15] ui: ensure notifications are properly sized - Fonts may not be fully loaded upon entering the menu, leading to incorrect calculated notification sizes. - Fix assertion when hovering a non-expiring notification. --- data/pigui/libs/notification.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/data/pigui/libs/notification.lua b/data/pigui/libs/notification.lua index 7f76fce973..d7c42aec0d 100644 --- a/data/pigui/libs/notification.lua +++ b/data/pigui/libs/notification.lua @@ -178,6 +178,8 @@ end local windowFlags = ui.WindowFlags { "NoDecoration", "NoBackground", "NoMove" } +local frameCounter = 3 + ui.registerModule('notification', function() if #Notification.queue == 0 then return @@ -205,7 +207,7 @@ ui.registerModule('notification', function() table.remove(Notification.queue, i) else -- TODO(screen-resize): this has to be re-calculated if the screen width changes - if not notif.size then + if not notif.size or frameCounter > 0 then calcNotificationSize(notif, wrapWidth) end @@ -213,6 +215,8 @@ ui.registerModule('notification', function() end end + frameCounter = math.max(frameCounter - 1, 0) + -- Grow vertically, but fix horizontal size local windowHeight = math.min(maxHeight, ui.screenHeight) ui.setNextWindowSize(Vector2(maxWidth, windowHeight), "Always") @@ -243,7 +247,7 @@ ui.registerModule('notification', function() local hovered = drawNotification(notif, wrapWidth) -- Prevent this notification from expiring while hovered - if hovered then + if hovered and notif.expiry then notif.expiry = notif.expiry + Engine.frameTime end From 09249711ee21e1891b87dca7bf81a7edb49848d2 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 20:28:52 -0500 Subject: [PATCH 03/15] Add onEnterMainMenu event - Hook for various at-startup checks to run, including validity/integrity checks to make errors visible before starting or loading a game. --- data/libs/Event.lua | 22 ++++++++++++++++++++++ src/Pi.cpp | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/data/libs/Event.lua b/data/libs/Event.lua index ec22cbb678..099b2f0e79 100644 --- a/data/libs/Event.lua +++ b/data/libs/Event.lua @@ -240,6 +240,28 @@ Event.New = function() return self end +-- +-- Event: onEnterMainMenu +-- +-- Triggered when the menu is loaded. +-- +-- > local onMenuLoaded = function () ... end +-- > Event.Register("onEnterMainMenu", onMenuLoaded) +-- +-- onMenuLoaded is triggered once the menu is fully available +-- +-- This is a good place to perform startup checks, including checking for +-- errors and making them visible to the player +-- +-- Availability: +-- +-- 2025-02-03 +-- +-- Status: +-- +-- stable +-- + -- -- Event: onGameStart -- diff --git a/src/Pi.cpp b/src/Pi.cpp index 47d545e889..4c812906a0 100644 --- a/src/Pi.cpp +++ b/src/Pi.cpp @@ -679,6 +679,8 @@ void MainMenu::Start() perfInfoDisplay->ClearCounter(PiGui::PerfInfo::COUNTER_PHYS); perfInfoDisplay->ClearCounter(PiGui::PerfInfo::COUNTER_PIGUI); + + LuaEvent::Queue("onEnterMainMenu"); } void MainMenu::Update(float deltaTime) @@ -690,6 +692,8 @@ void MainMenu::Update(float deltaTime) Pi::intro->Draw(deltaTime); + LuaEvent::Emit(); + Pi::pigui->NewFrame(); PiGui::EmitEvents(); PiGui::RunHandler(deltaTime, "mainMenu"); From 4df06721548a5c08ae8f05252b6c635e9a3aca87 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 23:55:51 -0500 Subject: [PATCH 04/15] LuaShipDef: expose path to originating ship JSON file --- data/libs/HullConfig.lua | 2 ++ data/meta/ShipDef.lua | 1 + src/lua/LuaShipDef.cpp | 1 + 3 files changed, 4 insertions(+) diff --git a/data/libs/HullConfig.lua b/data/libs/HullConfig.lua index 1c8ad21971..419e13ca45 100644 --- a/data/libs/HullConfig.lua +++ b/data/libs/HullConfig.lua @@ -41,6 +41,7 @@ Slot.gimbal = nil ---@type table? local HullConfig = utils.proto("HullConfig") HullConfig.id = "" +HullConfig.path = "" HullConfig.equipCapacity = 0 -- Default slot config for a new shipdef @@ -71,6 +72,7 @@ local function CreateShipConfig(def) Serializer:RegisterPersistent("ShipDef." .. def.id, newShip) newShip.id = def.id + newShip.path = def.path newShip.equipCapacity = def.equipCapacity table.merge(newShip.slots, def.raw.equipment_slots or {}, function(name, slotDef) diff --git a/data/meta/ShipDef.lua b/data/meta/ShipDef.lua index 433e132631..b5bfdf7def 100644 --- a/data/meta/ShipDef.lua +++ b/data/meta/ShipDef.lua @@ -9,6 +9,7 @@ ---@class ShipDef ---@field id string +---@field path string ---@field name string ---@field shipClass string ---@field manufacturer string diff --git a/src/lua/LuaShipDef.cpp b/src/lua/LuaShipDef.cpp index ff31e1de9b..d5537f3873 100644 --- a/src/lua/LuaShipDef.cpp +++ b/src/lua/LuaShipDef.cpp @@ -238,6 +238,7 @@ void LuaShipDef::Register() lua_newtable(l); pi_lua_settable(l, "id", iter.first.c_str()); + pi_lua_settable(l, "path", st.definitionPath.c_str()); pi_lua_settable(l, "name", st.name.c_str()); pi_lua_settable(l, "shipClass", st.shipClass.c_str()); pi_lua_settable(l, "manufacturer", st.manufacturer.c_str()); From bd01aab3853d7c72f303196d28d03c0f756f8a35 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 20:24:23 -0500 Subject: [PATCH 05/15] HullConfig: add required slot field - Indicates that the slot is a "required" slot and the contents must not be empty - Implicitly requires the "default" field to be populated with a valid equipment ID --- data/libs/HullConfig.lua | 1 + data/modules/Debug/DebugShip.lua | 3 +++ 2 files changed, 4 insertions(+) diff --git a/data/libs/HullConfig.lua b/data/libs/HullConfig.lua index 419e13ca45..cbdc9a531b 100644 --- a/data/libs/HullConfig.lua +++ b/data/libs/HullConfig.lua @@ -23,6 +23,7 @@ Slot.size = 1 Slot.size_min = nil ---@type number? Slot.tag = nil ---@type string? Slot.default = nil ---@type string? +Slot.required = false ---@type boolean Slot.hardpoint = false Slot.i18n_key = nil ---@type string? Slot.i18n_res = "equipment-core" diff --git a/data/modules/Debug/DebugShip.lua b/data/modules/Debug/DebugShip.lua index ee471d789e..3dad4f90c9 100644 --- a/data/modules/Debug/DebugShip.lua +++ b/data/modules/Debug/DebugShip.lua @@ -366,6 +366,9 @@ function DebugShipTool:drawSlotDetail(slot) drawSlotValue(slot, "size_min") drawSlotValue(slot, "tag") drawSlotValue(slot, "default") + if slot.required then + drawSlotValue(slot, "required") + end drawSlotValue(slot, "hardpoint") drawSlotValue(slot, "count") From eaacaf220eaf3f298eb8bc3d2fb75f513c31c0c9 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Mon, 13 Jan 2025 16:13:12 -0500 Subject: [PATCH 06/15] EquipType: add isInstance() helper --- data/libs/EquipSet.lua | 1 + data/libs/EquipType.lua | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/data/libs/EquipSet.lua b/data/libs/EquipSet.lua index 3107e1a513..9304729636 100644 --- a/data/libs/EquipSet.lua +++ b/data/libs/EquipSet.lua @@ -442,6 +442,7 @@ end ---@param slotHandle HullConfig.Slot? ---@return boolean function EquipSet:Install(equipment, slotHandle) + assert(equipment:isInstance()) local slotId = self.idCache[slotHandle] if slotHandle then diff --git a/data/libs/EquipType.lua b/data/libs/EquipType.lua index 374c8ef359..989feb2936 100644 --- a/data/libs/EquipType.lua +++ b/data/libs/EquipType.lua @@ -161,6 +161,14 @@ function EquipType:isProto() return not rawget(self, "__proto") end +-- Method: isInstance +-- +-- Returns true if this object is an equipment item instance, false if it is +-- n prototype. +function EquipType:isInstance() + return rawget(self, "__proto") ~= nil +end + -- Method: GetPrototype -- -- Return the prototype this equipment item instance is derived from, or the @@ -190,6 +198,7 @@ end -- instance represents. ---@param count integer function EquipType:SetCount(count) + assert(self:isInstance()) local proto = self:GetPrototype() self.mass = proto.mass * count From b7d285095d4ef5bd0b249fb29b2ca6e2d5224626 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 20:25:22 -0500 Subject: [PATCH 07/15] ShipBuilder: basic support for required slots - Fill required slots with specified default items if present. - Currently no ability to replace default items in required slots via an autofit rule. --- data/modules/MissionUtils/ShipBuilder.lua | 34 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/data/modules/MissionUtils/ShipBuilder.lua b/data/modules/MissionUtils/ShipBuilder.lua index ec3b8bd21e..fba45f5394 100644 --- a/data/modules/MissionUtils/ShipBuilder.lua +++ b/data/modules/MissionUtils/ShipBuilder.lua @@ -142,13 +142,14 @@ ShipPlan.freeVolume = 0 ShipPlan.equipMass = 0 ShipPlan.threat = 0 ShipPlan.freeThreat = 0 -ShipPlan.filled = {} -ShipPlan.equip = {} -ShipPlan.install = {} -ShipPlan.slots = {} +ShipPlan.filled = {} ---@type table +ShipPlan.equip = {} ---@type EquipType[] +ShipPlan.install = {} ---@type string[] +ShipPlan.slots = {} ---@type HullConfig.Slot[] function ShipPlan:__clone() self.filled = {} + self.default = {} self.equip = {} self.install = {} self.slots = {} @@ -614,6 +615,31 @@ function ShipBuilder.MakePlan(template, shipConfig, threat) shipConfig.id, hullThreat, threat}) end + -- Setup required equipment items for the ship + -- TODO: support op="replace" equipment rules to override required slots? + for _, slot in pairs(shipPlan.slots) do + + if slot.required then + local defaultEquip = Equipment.Get(slot.default) + + if defaultEquip then + + local inst = defaultEquip:Instance() + + if inst.SpecializeForShip then + inst:SpecializeForShip(shipPlan.config) + end + + if slot.count then + inst:SetCount(slot.count) + end + + shipPlan:AddEquipToPlan(inst, slot) + end + end + + end + for _, rule in ipairs(template.rules) do local canApplyRule = true From ddfd4d0c3a2946c320bc074ab241a1f48ead54ed Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Mon, 13 Jan 2025 00:07:31 -0500 Subject: [PATCH 08/15] ShipMarket: install default equipment items on ship purchase - All slots with default items will be populated on ship purchase. - Item cost is not yet factored into the overall cost of the ship. --- .../modules/station-view/04-shipMarket.lua | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/data/pigui/modules/station-view/04-shipMarket.lua b/data/pigui/modules/station-view/04-shipMarket.lua index 6ccb798574..06da31f76e 100644 --- a/data/pigui/modules/station-view/04-shipMarket.lua +++ b/data/pigui/modules/station-view/04-shipMarket.lua @@ -151,10 +151,44 @@ local function buyShip (mkt, sos) if sos.pattern then player.model:SetPattern(sos.pattern) end player:SetLabel(sos.label) - -- TODO: ships on sale should have their own pre-installed set of equipment - -- items instead of being completely empty + -- TODO: ship ads should support an explicit list of (pre-owned) equipment as well as / instead of factory-default items + -- FIXME: ship advertisements don't include the cost of default items - if def.hyperdriveClass > 0 then + local equipSet = player:GetComponent('EquipSet') + + for _, slot in pairs(equipSet.config.slots) do + + if slot.default then + + local newEquip = Equipment.Get(slot.default) + + if newEquip then + local inst = newEquip:Instance() + + if inst.SpecializeForShip then + inst:SpecializeForShip(equipSet.config) + end + + if slot.count then + inst:SetCount(slot.count) + end + + local handle = assert(equipSet:GetSlotHandle(slot.id)) + + if equipSet:CanInstallInSlot(handle, inst) then + equipSet:Install(inst, handle) + else + logWarning("Default equipment item {} for ship slot {}.{} is not compatible with slot." % { slot.default, player.shipId, slot.id }) + end + + end + + end + + end + + -- NOTE: fallback pass. Hyperdrives should be specified as a default item on the hyperdrive slot + if def.hyperdriveClass > 0 and not player:GetInstalledHyperdrive() then local slot = player:GetComponent('EquipSet'):GetAllSlotsOfType('hyperdrive')[1] -- Install the best-fitting non-military hyperdrive we can From e75debca29d904b961db365b14ace5602878a569 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 23:03:18 -0500 Subject: [PATCH 09/15] Debug: add load-time validation module - Framework to run a number of at-startup data validation tasks and collate the results into an easily-browsable display for modders/developers --- data/modules/Debug/DebugLoader.lua | 185 +++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 data/modules/Debug/DebugLoader.lua diff --git a/data/modules/Debug/DebugLoader.lua b/data/modules/Debug/DebugLoader.lua new file mode 100644 index 0000000000..6303f18f15 --- /dev/null +++ b/data/modules/Debug/DebugLoader.lua @@ -0,0 +1,185 @@ +-- Copyright © 2008-2025 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local Event = require 'Event' +local utils = require 'utils' + +local ui = require 'pigui' + +local colors = ui.theme.colors +local icons = ui.theme.icons + +local Notification = require 'pigui.libs.notification' +local debugView = require 'pigui.views.debug' + +local messageData = { + log = {}, + bySource = utils.automagic() +} + +local activeLoader = nil +local messageCount = {} + +--============================================================================= + +local DebugLoader = {} + +DebugLoader.Type = { + Error = "ERROR", + Warn = "WARN", + Info = "INFO" +} + +DebugLoader.checks = {} + +---@param name string +---@param func fun() +function DebugLoader.RegisterCheck(name, func) + table.insert(DebugLoader.checks, { + id = name, + run = func + }) +end + +---@param type string One of DebugLoader.Type +---@param message string +function DebugLoader.LogMessage(type, message) + table.insert(messageData.log, { type, message, activeLoader }) + messageCount[type] = (messageCount[type] or 0) + 1 + + print("validation {}: {}" % { type, message }) +end + +---@param type string One of DebugLoader.Type +---@param message string +function DebugLoader.LogFileMessage(type, source, message) + table.insert(messageData.bySource[source], { type, message, activeLoader }) + table.insert(messageData.log, { type, message, source }) + messageCount[type] = (messageCount[type] or 0) + 1 + + print("validation {} [{}]: {}" % { type, source, message }) +end + +--============================================================================= + +local function scanForErrors() + for _, check in ipairs(DebugLoader.checks) do + activeLoader = check.id + check.run() + end +end + +--============================================================================= + +local DebugLoaderUI = utils.class("Debug.LoaderUI", require 'pigui.libs.module') + +local msg_order = { + DebugLoader.Type.Error, + DebugLoader.Type.Warn, + DebugLoader.Type.Info, +} + +local msg_icons = { + [DebugLoader.Type.Error] = icons.alert_generic, + [DebugLoader.Type.Warn] = icons.view_internal, + [DebugLoader.Type.Info] = icons.info +} + +local msg_colors = { + [DebugLoader.Type.Error] = ui.theme.styleColors.danger_300, + [DebugLoader.Type.Warn] = ui.theme.styleColors.warning_300, + [DebugLoader.Type.Info] = colors.font +} + +function DebugLoaderUI:Constructor() + self.Super().Constructor(self) + + self.activeSource = nil +end + +function DebugLoaderUI:setActiveSource(source) + self.activeSource = source +end + +function DebugLoaderUI:render() + ui.horizontalGroup(function() + for _, type in ipairs(msg_order) do + ui.icon(msg_icons[type], Vector2(ui.getTextLineHeight()), msg_colors[type]) + ui.text(ui.Format.Number(messageCount[type] or 0, 0)) + end + end) + + local function getSourceName(source) + return "{} ({})" % { source or "All", ui.Format.Number((source and #messageData.bySource[source] or #messageData.log), 0) } + end + + local sourceList = utils.build_array(utils.keys(messageData.bySource)) + table.sort(sourceList) + + ui.comboBox("Source Filter", getSourceName(self.activeSource), function() + if ui.selectable(getSourceName(nil)) then + self:message("setActiveSource", nil) + end + + for _, source in ipairs(sourceList) do + if ui.selectable(getSourceName(source)) then + self:message("setActiveSource", source) + end + end + end) + + ui.separator() + + ui.child("MessageView", function() + + ui.pushTextWrapPos(ui.getContentRegion().x) + + local source = self.activeSource and messageData.bySource[self.activeSource] or messageData.log + + for _, log in ipairs(source) do + local type, msg, src = log[1], log[2], log[3] + ui.textColored(msg_colors[type], "[{}] {} {}" % { src, ui.get_icon_glyph(msg_icons[type]), msg }) + end + + ui.popTextWrapPos() + + end) +end + +--============================================================================= + +Event.Register("onEnterMainMenu", function() + -- Reset messages on menu load so they don't accumulate after each new game + messageData = { + log = {}, + bySource = utils.automagic() + } + + messageCount = {} + + scanForErrors() + + local message_body = "{} errors, {} warnings generated. See the debug Loading Messages tab (Ctrl+I) for more information." + + local numErrors = messageCount[DebugLoader.Type.Error] or 0 + local numWarnings = messageCount[DebugLoader.Type.Warn] or 0 + + if numErrors > 0 or numWarnings > 0 then + Notification.add(Notification.Type.Error, "Validation Issues Found", + message_body % { numErrors, numWarnings }, + icons.repairs) + end +end) + +debugView.registerTab("Loader", { + label = "Loading Messages", + icon = icons.repairs, + debugUI = DebugLoaderUI.New(), + show = function() return utils.count(messageCount) > 0 end, + draw = function(self) + self.debugUI:update() + self.debugUI:render() + end +}) + +return DebugLoader From 8692b005cf225e60925f7a9543255b0934391fcf Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Jan 2025 23:56:36 -0500 Subject: [PATCH 10/15] Debug: sort debug view tabs by priority or name --- data/pigui/views/debug.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/data/pigui/views/debug.lua b/data/pigui/views/debug.lua index c8d4c4c14a..17b3405ec5 100644 --- a/data/pigui/views/debug.lua +++ b/data/pigui/views/debug.lua @@ -52,6 +52,10 @@ function debugView.registerTab(name, tab) local index = debugView.tabs[name] or #debugView.tabs + 1 debugView.tabs[index] = tab debugView.tabs[name] = index + + table.sort(debugView.tabs, function(a, b) + return (a.priority and not b.priority) or (a.priority and b.priority and a.priority < b.priority) or (a.priority == b.priority and a.label < b.label) + end) end function debugView.drawTabs(delta) From 886b34f0a7e1da8dbc56189d027cdd020b73d2d1 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Mon, 13 Jan 2025 00:03:24 -0500 Subject: [PATCH 11/15] Debug: add startup validation of ship json files - Checks for most common issues with ship configs. - Meant to ensure crash bugs with ship configs don't slip through into a release. - Also applies to modded ship files. --- data/modules/Debug/CheckShipData.lua | 148 +++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 data/modules/Debug/CheckShipData.lua diff --git a/data/modules/Debug/CheckShipData.lua b/data/modules/Debug/CheckShipData.lua new file mode 100644 index 0000000000..1811a59c48 --- /dev/null +++ b/data/modules/Debug/CheckShipData.lua @@ -0,0 +1,148 @@ +-- Copyright © 2008-2025 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local Equipment = require 'Equipment' +local HullConfig = require 'HullConfig' +local Loader = require '.DebugLoader' +local EquipSet = require 'EquipSet' +local Lang = require 'Lang' +local ShipDef = require 'ShipDef' + +local utils = require 'utils' + +-- This file implements validation passes for ship JSON files +-- It's intended to catch most common errors, especially those that would be +-- difficult to find outside of switching to each ship type in sequence. + +local activeFile = nil + +local error = function(message) Loader.LogFileMessage(Loader.Type.Error, activeFile, message) end +local warn = function(message) Loader.LogFileMessage(Loader.Type.Warn, activeFile, message) end +local info = function(message) Loader.LogFileMessage(Loader.Type.Info, activeFile, message) end + +local function findMatchingSlots(config, type) + return utils.filter_table(config.slots, function(_, slot) + return EquipSet.SlotTypeMatches(slot.type, type) + end) +end + +---@param slot HullConfig.Slot +local function checkSlot(slot) + + if string.match(slot.id, "##") then + error("Slot {id} name contains invalid sequence '##'." % slot) + end + + if not string.match(slot.id, "^[a-zA-Z0-9_]+$") then + warn("Slot {id} name contains non-identifier characters." % slot) + end + + if slot.required and not slot.default then + error("Slot {id} is a required slot but does not have a default equipment item." % slot) + end + + if slot.default and not Equipment.Get(slot.default) then + error("Slot {id} default item ({default}) does not exist." % slot) + end + + if EquipSet.SlotTypeMatches(slot.type, "hyperdrive") and not slot.default then + warn("Slot {id} has no default hyperdrive equipment." % slot) + end + + if slot.i18n_key then + if not slot.i18n_res then + error("Slot {id} has an invalid language resource key {i18n_res}." % slot) + end + + local res = Lang.GetResource(slot.i18n_res) + + if not rawget(res, slot.i18n_key) then + warn("Slot {id} uses undefined lang string '{i18n_res}.{i18n_key}'." % slot) + end + end + + local isWeaponType = EquipSet.SlotTypeMatches(slot.type, "weapon") + local isPylonType = EquipSet.SlotTypeMatches(slot.type, "pylon") + local isBayType = EquipSet.SlotTypeMatches(slot.type, "missile_bay") + local isScoopType = EquipSet.SlotTypeMatches(slot.type, "fuel_scoop") + + local isExternal = isWeaponType or isPylonType or isBayType or isScoopType + + if isExternal then + + if not slot.hardpoint then + error("External slot {id} with type {type} should have hardpoint=true." % slot) + end + + if not slot.tag then + info("External slot {id} with type {type} is missing an associated tag." % slot) + end + + end + + if isWeaponType then + + if not slot.gimbal or type(slot.gimbal) ~= "table" then + error("Weapon slot {id} is missing gimbal data." % slot) + elseif type(slot.gimbal[1]) ~= "number" or type(slot.gimbal[2]) ~= "number" then + error("Weapon slot {id} should have a two-axis gimbal expressed as [x, y]." % slot) + end + + end + +end + +---@param config HullConfig +local function checkConfig(config) + if utils.count(findMatchingSlots(config, "hyperdrive")) > 1 then + error("Ship {id} has more than one hyperdrive slot; this will break module code." % config) + end + + if utils.count(findMatchingSlots(config, "thruster")) == 0 then + warn("Ship {id} has no thruster slots. This may break in the future.") + end + + -- TODO: more validation passes on the whole ship config +end + +---@param shipDef ShipDef +local function checkShipDef(shipDef) + if shipDef.tag ~= "SHIP" then + return + end + + if utils.count(shipDef.roles) == 0 then + info("Ship {id} has no roles and will not be used by most modules." % shipDef) + end + + if shipDef.minCrew > shipDef.maxCrew then + error("Ship {id} has minCrew {minCrew} > maxCrew {maxCrew}." % shipDef) + end + + if not shipDef.shipClass or shipDef.shipClass == "" then + warn("Ship {id} has invalid/empty ship_class field." % shipDef) + end + + if not shipDef.manufacturer then + info("Ship {id} has no manufacturer set." % shipDef) + end +end + +Loader.RegisterCheck("HullConfigs", function() + + local configs = HullConfig.GetHullConfigs() + + for _, config in pairs(configs) do + activeFile = config.path + + for _, slot in pairs(config.slots) do checkSlot(slot) end + checkConfig(config) + end + + for _, shipDef in pairs(ShipDef) do + activeFile = shipDef.path + + checkShipDef(shipDef) + end + +end) From de1cb2413761be3aeb5c97bdb4b7791c93490072 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Mon, 13 Jan 2025 16:40:20 -0500 Subject: [PATCH 12/15] Outfitter: prevent selling required equipment - Equipment in required slots can be replaced, but cannot be sold such that the slot is left empty. --- data/pigui/libs/equipment-outfitter.lua | 11 +++++++---- data/pigui/libs/ship-equipment.lua | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/data/pigui/libs/equipment-outfitter.lua b/data/pigui/libs/equipment-outfitter.lua index cd7ea1ad57..21bbc21a7c 100644 --- a/data/pigui/libs/equipment-outfitter.lua +++ b/data/pigui/libs/equipment-outfitter.lua @@ -123,11 +123,11 @@ function EquipCardUnavailable:tooltipContents(data, isSelected) if not data.canInstall then ui.textWrapped(l.NOT_SUPPORTED_ON_THIS_SHIP % { equipment = data.name } .. ".") elseif not data.canReplace then - ui.textWrapped(l.CANNOT_SELL_NONEMPTY_EQUIP .. ".") + ui.textWrapped(l.CANNOT_SELL_NONEMPTY_EQUIP) elseif data.outOfStock then - ui.textWrapped(l.OUT_OF_STOCK) + ui.textWrapped(l.ITEM_IS_OUT_OF_STOCK) else - ui.textWrapped(l.YOU_NOT_ENOUGH_MONEY) + ui.textWrapped(l.YOU_NOT_ENOUGH_MONEY .. ".") end end) @@ -178,6 +178,7 @@ function Outfitter:Constructor() self.station = nil ---@type SpaceStation self.filterSlot = nil ---@type HullConfig.Slot? self.replaceEquip = nil ---@type EquipType? + self.canReplaceEquip = false self.canSellEquip = false self.sortId = nil ---@type string? @@ -305,7 +306,7 @@ function Outfitter:buildEquipmentList() data.canInstall = equipSet:CanInstallLoose(equip) end - data.canReplace = not self.replaceEquip or self.canSellEquip + data.canReplace = not self.replaceEquip or self.canReplaceEquip data.outOfStock = data.count <= 0 @@ -429,6 +430,7 @@ function Outfitter:drawEquipmentItem(data, isSelected) end end +---@param data UI.EquipmentOutfitter.EquipData function Outfitter:drawBuyButton(data) local icon = icons.autopilot_dock local price_text = ui.Format.Money(self:getInstallPrice(data.equip)) @@ -439,6 +441,7 @@ function Outfitter:drawBuyButton(data) end end +---@param data UI.EquipCard.Data function Outfitter:drawSellButton(data) local icon = icons.autopilot_undock_illegal local price_text = ui.Format.Money(self:getSellPrice(data.equip)) diff --git a/data/pigui/libs/ship-equipment.lua b/data/pigui/libs/ship-equipment.lua index 182924c267..1579460554 100644 --- a/data/pigui/libs/ship-equipment.lua +++ b/data/pigui/libs/ship-equipment.lua @@ -187,9 +187,12 @@ function EquipmentWidget:onSelectSlot(slotData, children) self.selectedEquip = slotData.equip self.selectionActive = true + local hasChildren = children and children.count > 0 + self.market.filterSlot = self.selectedSlot self.market.replaceEquip = self.selectedEquip - self.market.canSellEquip = not children or children.count == 0 + self.market.canReplaceEquip = not hasChildren + self.market.canSellEquip = not (self.selectedSlot and self.selectedSlot.required or hasChildren) self.market:refresh() end end From f7aedcdf40226045d1d522bb2afec23871202fb6 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Mon, 13 Jan 2025 16:42:16 -0500 Subject: [PATCH 13/15] Rename thruster equipment - Remove the 'misc' equipment namespace, move thruster equipment into their own namespace. --- data/modules/Equipment/Internal.lua | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/data/modules/Equipment/Internal.lua b/data/modules/Equipment/Internal.lua index d21204bb0e..39047b55d3 100644 --- a/data/modules/Equipment/Internal.lua +++ b/data/modules/Equipment/Internal.lua @@ -126,7 +126,7 @@ Equipment.Register("misc.hull_autorepair", EquipType.New { --=============================================== -- S1 thrusters -Equipment.Register("misc.thrusters_default_s1", ThrusterType.New { +Equipment.Register("thruster.default_s1", ThrusterType.New { l10n_key="THRUSTERS_DEFAULT", slots="thruster", price=120, purchasable=true, tech_level=2, slot = { type="thruster", size=1 }, @@ -134,7 +134,7 @@ Equipment.Register("misc.thrusters_default_s1", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_improved_s1", ThrusterType.New { +Equipment.Register("thruster.improved_s1", ThrusterType.New { l10n_key="THRUSTERS_IMPROVED", slots="thruster", price=250, purchasable=true, tech_level=5, slot = { type="thruster", size=1 }, @@ -142,7 +142,7 @@ Equipment.Register("misc.thrusters_improved_s1", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_optimised_s1", ThrusterType.New { +Equipment.Register("thruster.optimised_s1", ThrusterType.New { l10n_key="THRUSTERS_OPTIMISED", slots="thruster", price=560, purchasable=true, tech_level=8, slot = { type="thruster", size=1 }, @@ -150,7 +150,7 @@ Equipment.Register("misc.thrusters_optimised_s1", ThrusterType.New { icon_name="equip_thrusters_medium" }) -Equipment.Register("misc.thrusters_naval_s1", ThrusterType.New { +Equipment.Register("thruster.naval_s1", ThrusterType.New { l10n_key="THRUSTERS_NAVAL", slots="thruster", price=1400, purchasable=true, tech_level="MILITARY", slot = { type="thruster", size=1 }, @@ -159,7 +159,7 @@ Equipment.Register("misc.thrusters_naval_s1", ThrusterType.New { }) -- S2 thrusters -Equipment.Register("misc.thrusters_default_s2", ThrusterType.New { +Equipment.Register("thruster.default_s2", ThrusterType.New { l10n_key="THRUSTERS_DEFAULT", slots="thruster", price=220, purchasable=true, tech_level=2, slot = { type="thruster", size=2 }, @@ -167,7 +167,7 @@ Equipment.Register("misc.thrusters_default_s2", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_improved_s2", ThrusterType.New { +Equipment.Register("thruster.improved_s2", ThrusterType.New { l10n_key="THRUSTERS_IMPROVED", slots="thruster", price=460, purchasable=true, tech_level=5, slot = { type="thruster", size=2 }, @@ -175,7 +175,7 @@ Equipment.Register("misc.thrusters_improved_s2", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_optimised_s2", ThrusterType.New { +Equipment.Register("thruster.optimised_s2", ThrusterType.New { l10n_key="THRUSTERS_OPTIMISED", slots="thruster", price=1025, purchasable=true, tech_level=8, slot = { type="thruster", size=2 }, @@ -183,7 +183,7 @@ Equipment.Register("misc.thrusters_optimised_s2", ThrusterType.New { icon_name="equip_thrusters_medium" }) -Equipment.Register("misc.thrusters_naval_s2", ThrusterType.New { +Equipment.Register("thruster.naval_s2", ThrusterType.New { l10n_key="THRUSTERS_NAVAL", slots="thruster", price=2565, purchasable=true, tech_level="MILITARY", slot = { type="thruster", size=2 }, @@ -192,7 +192,7 @@ Equipment.Register("misc.thrusters_naval_s2", ThrusterType.New { }) -- S3 thrusters -Equipment.Register("misc.thrusters_default_s3", ThrusterType.New { +Equipment.Register("thruster.default_s3", ThrusterType.New { l10n_key="THRUSTERS_DEFAULT", slots="thruster", price=420, purchasable=true, tech_level=2, slot = { type="thruster", size=3 }, @@ -200,7 +200,7 @@ Equipment.Register("misc.thrusters_default_s3", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_improved_s3", ThrusterType.New { +Equipment.Register("thruster.improved_s3", ThrusterType.New { l10n_key="THRUSTERS_IMPROVED", slots="thruster", price=880, purchasable=true, tech_level=5, slot = { type="thruster", size=3 }, @@ -208,7 +208,7 @@ Equipment.Register("misc.thrusters_improved_s3", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_optimised_s3", ThrusterType.New { +Equipment.Register("thruster.optimised_s3", ThrusterType.New { l10n_key="THRUSTERS_OPTIMISED", slots="thruster", price=1950, purchasable=true, tech_level=8, slot = { type="thruster", size=3 }, @@ -216,7 +216,7 @@ Equipment.Register("misc.thrusters_optimised_s3", ThrusterType.New { icon_name="equip_thrusters_medium" }) -Equipment.Register("misc.thrusters_naval_s3", ThrusterType.New { +Equipment.Register("thruster.naval_s3", ThrusterType.New { l10n_key="THRUSTERS_NAVAL", slots="thruster", price=4970, purchasable=true, tech_level="MILITARY", slot = { type="thruster", size=3 }, @@ -225,7 +225,7 @@ Equipment.Register("misc.thrusters_naval_s3", ThrusterType.New { }) -- S4 Thrusters -Equipment.Register("misc.thrusters_default_s4", ThrusterType.New { +Equipment.Register("thruster.default_s4", ThrusterType.New { l10n_key="THRUSTERS_DEFAULT", slots="thruster", price=880, purchasable=true, tech_level=2, slot = { type="thruster", size=4 }, @@ -233,7 +233,7 @@ Equipment.Register("misc.thrusters_default_s4", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_improved_s4", ThrusterType.New { +Equipment.Register("thruster.improved_s4", ThrusterType.New { l10n_key="THRUSTERS_IMPROVED", slots="thruster", price=1850, purchasable=true, tech_level=5, slot = { type="thruster", size=4 }, @@ -241,7 +241,7 @@ Equipment.Register("misc.thrusters_improved_s4", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_optimised_s4", ThrusterType.New { +Equipment.Register("thruster.optimised_s4", ThrusterType.New { l10n_key="THRUSTERS_OPTIMISED", slots="thruster", price=4096, purchasable=true, tech_level=8, slot = { type="thruster", size=4 }, @@ -249,7 +249,7 @@ Equipment.Register("misc.thrusters_optimised_s4", ThrusterType.New { icon_name="equip_thrusters_medium" }) -Equipment.Register("misc.thrusters_naval_s4", ThrusterType.New { +Equipment.Register("thruster.naval_s4", ThrusterType.New { l10n_key="THRUSTERS_NAVAL", slots="thruster", price=10240, purchasable=true, tech_level="MILITARY", slot = { type="thruster", size=4 }, @@ -258,7 +258,7 @@ Equipment.Register("misc.thrusters_naval_s4", ThrusterType.New { }) -- S5 thrusters -Equipment.Register("misc.thrusters_default_s5", ThrusterType.New { +Equipment.Register("thruster.default_s5", ThrusterType.New { l10n_key="THRUSTERS_DEFAULT", slots="thruster", price=1950, purchasable=true, tech_level=2, slot = { type="thruster", size=5 }, @@ -266,7 +266,7 @@ Equipment.Register("misc.thrusters_default_s5", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_improved_s5", ThrusterType.New { +Equipment.Register("thruster.improved_s5", ThrusterType.New { l10n_key="THRUSTERS_IMPROVED", slots="thruster", price=4090, purchasable=true, tech_level=5, slot = { type="thruster", size=5 }, @@ -274,7 +274,7 @@ Equipment.Register("misc.thrusters_improved_s5", ThrusterType.New { icon_name="equip_thrusters_basic" }) -Equipment.Register("misc.thrusters_optimised_s5", ThrusterType.New { +Equipment.Register("thruster.optimised_s5", ThrusterType.New { l10n_key="THRUSTERS_OPTIMISED", slots="thruster", price=9050, purchasable=true, tech_level=8, slot = { type="thruster", size=5 }, @@ -282,7 +282,7 @@ Equipment.Register("misc.thrusters_optimised_s5", ThrusterType.New { icon_name="equip_thrusters_medium" }) -Equipment.Register("misc.thrusters_naval_s5", ThrusterType.New { +Equipment.Register("thruster.naval_s5", ThrusterType.New { l10n_key="THRUSTERS_NAVAL", slots="thruster", price=22620, purchasable=true, tech_level="MILITARY", slot = { type="thruster", size=5 }, From c128d8ca8c9cbb64440f76e529a6bd4b135711fa Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Mon, 13 Jan 2025 16:42:41 -0500 Subject: [PATCH 14/15] Update new-game-window with thruster changes --- data/pigui/modules/new-game-window/class.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/pigui/modules/new-game-window/class.lua b/data/pigui/modules/new-game-window/class.lua index 4bb7d47650..2512e7edea 100644 --- a/data/pigui/modules/new-game-window/class.lua +++ b/data/pigui/modules/new-game-window/class.lua @@ -32,7 +32,7 @@ local equipment2 = { sensor = "sensor.radar", hull_mod = "hull.atmospheric_shielding", hyperdrive = "hyperspace.hyperdrive_2", - thruster = "misc.thrusters_default_s1", + thruster = "thruster.default_s1", missile_bay_1 = "missile_bay.opli_internal_s2", missile_bay_2 = "missile_bay.opli_internal_s2", } From 582bdf282d6751210a98bc7401fcdec52d621ba8 Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Tue, 14 Jan 2025 03:41:02 -0500 Subject: [PATCH 15/15] ShipMarket: correctly handle cost of ship default equipment - Compute ship cost as basePrice + default equipment cost. - Cache a list of equipment when viewing advertisements and install on purchase. - Remove manual computation of hyperdrive cost in trade-in value since hyperdrives are included in ship costs. --- .../modules/station-view/04-shipMarket.lua | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/data/pigui/modules/station-view/04-shipMarket.lua b/data/pigui/modules/station-view/04-shipMarket.lua index 06da31f76e..d7516b9f51 100644 --- a/data/pigui/modules/station-view/04-shipMarket.lua +++ b/data/pigui/modules/station-view/04-shipMarket.lua @@ -32,6 +32,8 @@ local widgetSizes = ui.rescaleUI({ widgetSizes.iconSpacer = (widgetSizes.buyButton - widgetSizes.iconSize)/2 local shipMarket +---@type table 0 then - value = value - Equipment.new["hyperspace.hyperdrive_" .. shipDef.hyperdriveClass].price * equipSellPriceReduction - end - + -- We don't need to remove the hyperdrive from the value of the ship since the player is charged for it when buying the ship local equipment = ship:GetComponent("EquipSet"):GetInstalledEquipment() for _, e in pairs(equipment) do - local n = e.count or 1 - value = value + n * e.price * equipSellPriceReduction + value = value + e.price * equipSellPriceReduction end return math.ceil(value) @@ -114,7 +155,9 @@ local function buyShip (mkt, sos) local station = assert(player:GetDockedWith()) local def = sos.def - local cost = def.basePrice - tradeInValue(Game.player) + local shipData = advertDataCache[sos] + + local cost = shipData.price - tradeInValue(Game.player) if math.floor(cost) ~= cost then error("Ship price non-integer value.") end @@ -152,42 +195,23 @@ local function buyShip (mkt, sos) player:SetLabel(sos.label) -- TODO: ship ads should support an explicit list of (pre-owned) equipment as well as / instead of factory-default items - -- FIXME: ship advertisements don't include the cost of default items + -- At current we just build a list from the HullConfig's default items local equipSet = player:GetComponent('EquipSet') - for _, slot in pairs(equipSet.config.slots) do - - if slot.default then - - local newEquip = Equipment.Get(slot.default) - - if newEquip then - local inst = newEquip:Instance() - - if inst.SpecializeForShip then - inst:SpecializeForShip(equipSet.config) - end - - if slot.count then - inst:SetCount(slot.count) - end - - local handle = assert(equipSet:GetSlotHandle(slot.id)) - - if equipSet:CanInstallInSlot(handle, inst) then - equipSet:Install(inst, handle) - else - logWarning("Default equipment item {} for ship slot {}.{} is not compatible with slot." % { slot.default, player.shipId, slot.id }) - end - - end + -- Install pre-built list of default equipment into ship + for _, pair in ipairs(shipData.equip) do + local handle = assert(equipSet:GetSlotHandle(pair[1])) + if equipSet:CanInstallInSlot(handle, pair[2]) then + equipSet:Install(pair[2], handle) + else + logWarning("Default equipment item {} for ship slot {}.{} is not compatible with slot." % { slot.default, player.shipId, slot.id }) end - end - -- NOTE: fallback pass. Hyperdrives should be specified as a default item on the hyperdrive slot + -- FIXME: fallback pass. Hyperdrives should be specified as a default item on the hyperdrive slot + -- Once all hyperdrives are specified as default, this pass should be removed if def.hyperdriveClass > 0 and not player:GetInstalledHyperdrive() then local slot = player:GetComponent('EquipSet'):GetAllSlotsOfType('hyperdrive')[1] @@ -410,10 +434,12 @@ local tradeMenu = function() ui.text(shipClassString[selectedItem.def.shipClass]) end) + local cost = advertDataCache[selectedItem].price + ui.withFont(pionillium.heading, function() - ui.text(l.PRICE..": "..Format.Money(selectedItem.def.basePrice, false)) + ui.text(l.PRICE..": "..Format.Money(cost, false)) ui.sameLine() - ui.text(l.AFTER_TRADE_IN..": "..Format.Money(selectedItem.def.basePrice - tradeInValue(Game.player), false)) + ui.text(l.AFTER_TRADE_IN..": "..Format.Money(cost - tradeInValue(Game.player), false)) end) ui.nextColumn() @@ -512,7 +538,7 @@ shipMarket = Table.New("shipMarketWidget", false, { ui.text(item.def.name) ui.nextColumn() ui.dummy(widgetSizes.rowVerticalSpacing) - ui.text(Format.Money(item.def.basePrice,false)) + ui.text(Format.Money(advertDataCache[item].price, false)) ui.nextColumn() ui.dummy(widgetSizes.rowVerticalSpacing) ui.text(item.def.equipCapacity.."t")