package.path = package.path .. ";data/scripts/lib/?.lua;data/scripts/?.lua;?" include("callable") include("stringutility") -- namespace AethelspaceHubPopup AethelspaceHubPopup = {} local window = nil local statusLabel = nil local captainLabel = nil local captainIcon = nil local captainNameLabel = nil local captainPrimaryLabel = nil local captainSecondaryLabel = nil local captainPerkLabel = nil local crewHeader = nil local rosterLabel = nil local intelLabel = nil local megaLabel = nil local crewRows = {} local trackedText = "" local shipText = "" local peopleChoices = {} local shipChoices = {} local specialistCombo = nil local jobCombo = nil local shipCombo = nil local controlsFilled = false local JOB_KEYS = { "Engine", "Repair", "Gunner", "Miner", "Pilot", "Security", "Attacker" } local JOB_LABELS = { Engine = "Engineer", Repair = "Mechanic", Gunner = "Gunner", Miner = "Miner", Pilot = "Pilot", Security = "Security", Attacker = "Boarder", } local seen = false local want_show = false local k_was_down = false if onClient() then function AethelspaceHubPopup.getUpdateInterval() return 0 end getUpdateInterval = AethelspaceHubPopup.getUpdateInterval function AethelspaceHubPopup.buildWindow() local res = getResolution() if not res or res.x < 200 or res.y < 200 then window = nil return end local size = vec2(700, 560) local pos = vec2(math.max(20, (res.x - size.x) * 0.5), math.max(20, (res.y - size.y) * 0.5)) window = Hud():createWindow(Rect(pos, pos + size)) window.caption = "Aethelspace Hub" window.showCloseButton = true window.closeableWithEscape = true window.moveable = true local tabsOk, tabErr = pcall(function() local tabs = window:createTabbedWindow(Rect(vec2(8, 8), size - vec2(8, 8))) local personnel = tabs:createTab("Personnel"%_t, "data/textures/icons/crew.png", "Crew and captains"%_t) local pw = (personnel.size and personnel.size.x) or 680 local ph = (personnel.size and personnel.size.y) or 500 pcall(function() captainIcon = personnel:createCaptainIcon(Rect(vec2(10, 8), vec2(78, 76))) end) captainNameLabel = personnel:createLabel(Rect(vec2(88, 6), vec2(pw - 12, 24)), "Captain", 14) captainPrimaryLabel = personnel:createLabel(Rect(vec2(88, 26), vec2(pw - 12, 42)), "", 13) captainSecondaryLabel = personnel:createLabel(Rect(vec2(88, 44), vec2(pw - 12, 60)), "", 13) captainPerkLabel = personnel:createLabel(Rect(vec2(88, 62), vec2(pw - 12, 80)), "", 12) captainPerkLabel.wordBreak = true captainLabel = captainNameLabel for _, label in pairs({ captainNameLabel, captainPrimaryLabel, captainSecondaryLabel }) do pcall(function() label.font = FontType.SciFi end) end crewHeader = personnel:createLabel(Rect(vec2(10, 86), vec2(pw - 12, 104)), "Current crew", 13) local rowTop = 110 local rowH = 28 local maxRows = math.floor((ph - 112 - rowTop) / rowH) if maxRows > 14 then maxRows = 14 end if maxRows < 6 then maxRows = 6 end crewRows = {} for i = 1, maxRows do local top = rowTop + (i - 1) * rowH local icon = nil local bar = nil pcall(function() icon = personnel:createPicture(Rect(vec2(10, top), vec2(34, top + 24)), "") icon.isIcon = 1 icon:hide() end) pcall(function() bar = personnel:createCrewBar(Rect(vec2(40, top), vec2(176, top + 24))) bar.visiblePerCategory = 3 bar.visible = false end) local labelX = bar and 184 or 40 local label = personnel:createLabel(Rect(vec2(labelX, top), vec2(pw - 12, top + 24)), "", 13) label:hide() crewRows[i] = { icon = icon, bar = bar, label = label } end specialistCombo = personnel:createComboBox(Rect(vec2(10, ph - 104), vec2(pw * 0.56, ph - 80)), "onAssignmentPicked") specialistCombo:addEntry("Choose a specialist") jobCombo = personnel:createComboBox(Rect(vec2(pw * 0.58, ph - 104), vec2(pw - 12, ph - 80)), "onAssignmentPicked") for _, key in ipairs(JOB_KEYS) do jobCombo:addEntry(JOB_LABELS[key] or key) end shipCombo = personnel:createComboBox(Rect(vec2(10, ph - 74), vec2(pw * 0.56, ph - 50)), "onAssignmentPicked") shipCombo:addEntry("Choose a ship") personnel:createButton(Rect(vec2(pw * 0.58, ph - 74), vec2(pw - 12, ph - 50)), "Assign job", "onReassignClicked") personnel:createButton(Rect(vec2(10, ph - 42), vec2(pw * 0.48, ph - 12)), "Move to selected ship", "onTransferClicked") local promoteBtn = personnel:createButton(Rect(vec2(pw * 0.52, ph - 42), vec2(pw - 12, ph - 12)), "Promote to captain", "onPromoteClicked") promoteBtn.tooltip = "Seat the selected specialist in an empty captain's chair. Their seven job tracks choose the class, subclass, perks, and starting level." local colony = tabs:createTab("Colony"%_t, "data/textures/icons/round-star.png", "Colony and elevator"%_t) local cw = (colony.size and colony.size.x) or 680 local ch = (colony.size and colony.size.y) or 500 statusLabel = colony:createLabel(Rect(vec2(10, 8), vec2(cw - 10, ch - 118)), "Sector ledger: waiting.", 13) statusLabel.wordBreak = true local third = math.floor((cw - 28) / 3) local yFood = ch - 108 colony:createButton(Rect(vec2(10, yFood), vec2(10 + third, yFood + 32)), "Deliver 250 Food", "onDeliverFood") colony:createButton(Rect(vec2(14 + third, yFood), vec2(14 + third * 2, yFood + 32)), "Deliver 50 Meds", "onDeliverMeds") colony:createButton(Rect(vec2(18 + third * 2, yFood), vec2(cw - 10, yFood + 32)), "Deliver 10 Luxuries", "onDeliverLux") colony:createButton(Rect(vec2(10, ch - 68), vec2(cw - 10, ch - 32)), "Anchor Space Elevator (5k Iron, 250k Cr)", "onAnchorElevator") local megas = tabs:createTab("Megastructures"%_t, "data/textures/icons/station.png", "Projects and shipyard supply"%_t) local mw = (megas.size and megas.size.x) or 680 local mh = (megas.size and megas.size.y) or 500 megaLabel = megas:createLabel(Rect(vec2(10, 8), vec2(mw - 10, mh - 168)), "Megastructure ledger: waiting.", 13) megaLabel.wordBreak = true local mthird = math.floor((mw - 28) / 3) local function mega_row(tab, y, a, b, c, fa, fb, fc) tab:createButton(Rect(vec2(10, y), vec2(10 + mthird, y + 32)), a, fa) tab:createButton(Rect(vec2(14 + mthird, y), vec2(14 + mthird * 2, y + 32)), b, fb) tab:createButton(Rect(vec2(18 + mthird * 2, y), vec2(mw - 10, y + 32)), c, fc) end mega_row(megas, mh - 158, "Deposit 500 Steel", "Deposit 50 Processors", "Deposit all", "onDepositSteel", "onDepositProcessors", "onDepositAll") mega_row(megas, mh - 118, "Supply 100 Hull", "Supply 25 Electronics", "Supply 10 Shields", "onSupplyHull", "onSupplyElectronics", "onSupplyShields") mega_row(megas, mh - 78, "Orbital Ring (5M)", "Dyson Node (7.5M)", "Hypergate (10M)", "onCommissionRing", "onCommissionDyson", "onCommissionNexus") local intel = tabs:createTab("Intel"%_t, "data/textures/icons/shaking-hands.png", "Sovereignty"%_t) intelLabel = intel:createLabel(Rect(vec2(10, 10), vec2(intel.size.x - 10, intel.size.y - 20)), "Sovereignty: waiting for the ledger.", 13) intelLabel.wordBreak = true end) if not tabsOk then window:createLabel(Rect(vec2(12, 12), size - vec2(12, 12)), "Aethelspace Hub is open. Tabs failed: " .. tostring(tabErr), 14) end end function AethelspaceHubPopup.showWindow() -- The window created while the galaxy is still loading is not on the live HUD. -- Build a fresh one each time the player asks for the hub. window = nil statusLabel = nil captainLabel = nil captainIcon = nil captainNameLabel = nil captainPrimaryLabel = nil captainSecondaryLabel = nil captainPerkLabel = nil crewHeader = nil rosterLabel = nil intelLabel = nil megaLabel = nil crewRows = {} specialistCombo = nil jobCombo = nil shipCombo = nil controlsFilled = false AethelspaceHubPopup.buildWindow() if not window then return end window:show() if Hud() then Hud():addMouseShowingWindow(window) Hud():displayHint("Aethelspace Hub is open.") end AethelspaceHubPopup.refreshShipCrew() invokeServerFunction("noteAlive", "popup-shown") invokeServerFunction("requestHubData") end local function set_caption(widget, value) if not widget then return end -- Avorion labels display caption. Writing text leaves the original placeholder on screen. pcall(function() widget.caption = value end) pcall(function() widget.text = value end) end local function show_row(row, visible) if row.label then row.label.visible = visible end if row.icon then row.icon.visible = visible if visible then pcall(function() row.icon:show() end) else pcall(function() row.icon:hide() end) end end if row.bar then row.bar.visible = visible end end local function clean_text(value) if value == nil then return "" end local text = tostring(value):gsub("/%*.-%*/", "") return (text:gsub("^%s+", ""):gsub("%s+$", "")) end local function gendered_name(info, female) if not info then return "" end local name = (female and info.displayNameFemale and info.displayNameFemale ~= "" and info.displayNameFemale) or info.displayName or "" return clean_text(name) end local function profession_caption(crewman, num) local name = crewman.profession:name(num) local caption = nil pcall(function() if crewman.specialist then caption = "${profession} (Specialist level ${level})"%_t % {profession = name, level = crewman.level} else caption = "${profession}"%_t % {profession = name} end end) if caption and caption ~= "" and not string.find(caption, "${", 1, true) then return caption end if crewman.specialist then return string.format("%s (Specialist level %s)", name, tostring(crewman.level)) end return name end local function crew_tooltip(crewman, num) local profession = crewman.profession local caption = profession_caption(crewman, num) local built = nil pcall(function() local tip = Tooltip() tip.icon = profession.icon local head = TooltipLine(18, 13) head.ltext = caption head.lcolor = profession.color tip:addLine(head) if profession.description and profession.description ~= "" then local line = TooltipLine(15, 11) line.ltext = profession.description tip:addLine(line) end local aboard = TooltipLine(15, 12) aboard.ltext = "Aboard" aboard.rtext = tostring(num) tip:addLine(aboard) local level = TooltipLine(15, 12) level.ltext = "Level" level.rtext = tostring(crewman.level or 1) tip:addLine(level) built = tip end) if built then return built end local text = caption if profession.description and profession.description ~= "" then text = text .. "\n" .. profession.description end return text .. string.format("\nAboard: %d\nLevel %s", num, tostring(crewman.level)) end local function sorted_members(crew) local sorted = {} for crewman, num in pairs(crew:getMembers()) do local found = nil for _, entry in pairs(sorted) do if entry.crewman.level == crewman.level and entry.crewman.specialist == crewman.specialist and entry.crewman.profession == crewman.profession then entry.num = entry.num + num found = entry break end end if not found then table.insert(sorted, { crewman = crewman, num = num }) end end table.sort(sorted, function(pa, pb) local a = pa.crewman local b = pb.crewman if a.profession.value == b.profession.value then if a.specialist == b.specialist then return (a.level or 0) > (b.level or 0) end return (a.specialist and 1 or 0) > (b.specialist and 1 or 0) end return a.profession.value < b.profession.value end) return sorted end local function profession_key(profession) if not profession then return "" end local value = profession.value if value == CrewProfessionType.Engine then return "Engine" end if value == CrewProfessionType.Repair then return "Repair" end if value == CrewProfessionType.Gunner then return "Gunner" end if value == CrewProfessionType.Miner then return "Miner" end if value == CrewProfessionType.Pilot then return "Pilot" end if value == CrewProfessionType.Security then return "Security" end if value == CrewProfessionType.Attacker then return "Attacker" end return "" end local trackedPool = {} local function parse_tracked_line(line) local id, key, name, stars, level, tracks, elite = line:match("^([^|]+)|([^|]+)|([^|]+)|([^|]+)|([^|]+)|([^|]*)|([^|]*)$") if id and key and name then return { id = id, key = key, name = name, stars = stars, level = tonumber(level) or 1, tracks = tracks or "", elite = elite == "elite", } end key, name, stars, level = line:match("^([^|]+)|([^|]+)|([^|]+)|([^|]+)$") if key and name then return { id = "", key = key, name = name, stars = stars, level = tonumber(level) or 1, tracks = "", elite = false } end return nil end local function begin_tracked_pool() trackedPool = {} peopleChoices = {} for line in tostring(trackedText or ""):gmatch("[^\n]+") do local person = parse_tracked_line(line) if person then table.insert(peopleChoices, person) trackedPool[person.key] = trackedPool[person.key] or {} table.insert(trackedPool[person.key], person) end end end local function take_tracked(key, level, count) local list = trackedPool[key] local picked = {} if not list or count <= 0 then return picked end local function pull(match_level) local index = 1 while #picked < count and index <= #list do if (not match_level) or list[index].level == level then table.insert(picked, table.remove(list, index)) else index = index + 1 end end end pull(true) pull(false) return picked end local function attach_tracked(tip, key, level, count) local people = take_tracked(key, level, count) if #people == 0 then local note = "This specialist stack has no named record yet. The next census assigns a name and stars." if type(tip) ~= "string" then pcall(function() local line = TooltipLine(15, 11) line.ltext = note tip:addLine(line) end) return tip end return tip .. "\n" .. note end if type(tip) ~= "string" then pcall(function() for _, person in ipairs(people) do local line = TooltipLine(15, 12) line.ltext = string.format("%s %s★", person.name, person.stars) line.rtext = "specialist " .. tostring(person.level) tip:addLine(line) end local note = TooltipLine(14, 11) note.ltext = "Only the job they are working gains stars. Specialist level rises at 2.00 and 4.00." tip:addLine(note) for _, person in ipairs(people) do if person.tracks and person.tracks ~= "" then for token in person.tracks:gmatch("[^;]+") do local job, value = token:match("^([^=]+)=([%d%.]+)$") if job then local row = TooltipLine(14, 11) row.ltext = JOB_LABELS[job] or job row.rtext = value .. "★" tip:addLine(row) end end end if person.elite then local elite = TooltipLine(15, 12) elite.ltext = "Elite Crew" elite.rtext = "all seven jobs" tip:addLine(elite) end end end) return tip end local lines = {} for _, person in ipairs(people) do table.insert(lines, string.format("%s %s★ specialist level %s", person.name, person.stars, tostring(person.level))) end return tip .. "\n" .. table.concat(lines, "\n") .. "\nStars train this person. Specialist level rises at 2.00 and 4.00." end local function clear_captain(message) set_caption(captainNameLabel, message or "No captain") set_caption(captainPrimaryLabel, "") set_caption(captainSecondaryLabel, "") set_caption(captainPerkLabel, "") if captainNameLabel then captainNameLabel.tooltip = nil end if captainPrimaryLabel then captainPrimaryLabel.tooltip = nil end if captainSecondaryLabel then captainSecondaryLabel.tooltip = nil end if captainPerkLabel then captainPerkLabel.tooltip = nil end if captainIcon then pcall(function() captainIcon:setCaptain(nil) end) pcall(function() captainIcon.tooltip = nil end) end end function AethelspaceHubPopup.refreshShipCrew() local player = Player() local craft = valid(player) and player.craft or nil if not valid(craft) or not craft.crew then clear_captain("No ship is being piloted.") set_caption(crewHeader, "Current crew") for _, row in ipairs(crewRows) do show_row(row, false) end return end local size = 0 local maxSize = 0 pcall(function() size = craft.crew.size or 0 end) pcall(function() maxSize = craft.maxCrewSize or craft.crew.maxSize or size end) local header = string.format("Current crew (%d/%s)", size, tostring(maxSize)) pcall(function() header = "Current crew (${current}/${max})"%_t % {current = size, max = maxSize} end) set_caption(crewHeader, header) begin_tracked_pool() if crewHeader then if trackedText ~= "" then local pretty = {} for _, person in ipairs(peopleChoices) do local title = JOB_LABELS[person.key] or person.key if person.elite then title = title .. ", Elite Crew" end table.insert(pretty, string.format("%s: %s %s★ specialist level %s", title, person.name, person.stars, tostring(person.level))) end crewHeader.tooltip = #pretty > 0 and ("Named specialists\n" .. table.concat(pretty, "\n")) or trackedText else crewHeader.tooltip = "Specialists aboard this ship receive a name and a 5-star record. Ordinary crew stay as stacks." end end local cpt = nil pcall(function() cpt = craft:getCaptain() end) if not cpt then clear_captain("No captain") if captainNameLabel then captainNameLabel.tooltip = "The captain's chair is empty. Promote can fill it." end else set_caption(captainNameLabel, "Captain: " .. clean_text(cpt.displayName or cpt.name or "Captain")) pcall(function() if captainIcon then captainIcon:setCaptain(cpt) end end) pcall(function() local CaptainUtility = include("captainutility") local card = CaptainUtility.makeTooltip(cpt) local female = false pcall(function() female = CaptainGenderId and cpt.genderId ~= CaptainGenderId.Male end) local classes = CaptainUtility.ClassProperties() local perks = CaptainUtility.PerkProperties() local primary = classes[cpt.primaryClass] local secondary = classes[cpt.secondaryClass] if captainNameLabel then captainNameLabel.tooltip = card end if captainIcon then pcall(function() captainIcon.tooltip = card end) end local primaryName = gendered_name(primary, female) set_caption(captainPrimaryLabel, primaryName) if captainPrimaryLabel and primary then captainPrimaryLabel.color = primary.primaryColor local desc = (female and primary.descriptionFemale) or primary.description captainPrimaryLabel.tooltip = (desc and desc ~= "") and desc or card end local secondaryName = gendered_name(secondary, female) set_caption(captainSecondaryLabel, secondaryName) if captainSecondaryLabel then if secondary and secondaryName ~= "" then captainSecondaryLabel.color = secondary.secondaryColor local desc = (female and secondary.descriptionFemale) or secondary.description captainSecondaryLabel.tooltip = (desc and desc ~= "") and desc or nil else captainSecondaryLabel.tooltip = nil end end local names = {} local perkLines = {} for _, perk in pairs({cpt:getPerks()}) do local info = perks[perk] local name = gendered_name(info, female) if name ~= "" then table.insert(names, name) local desc = info and ((female and info.descriptionFemale) or info.description) or "" desc = clean_text(desc) if desc ~= "" then table.insert(perkLines, name .. ": " .. desc) else table.insert(perkLines, name) end end end if #names > 0 then set_caption(captainPerkLabel, "Perks: " .. table.concat(names, ", ")) else set_caption(captainPerkLabel, string.format("Tier %s, level %s", tostring(cpt.tier), tostring(cpt.level))) end if captainPerkLabel then captainPerkLabel.tooltip = #perkLines > 0 and table.concat(perkLines, "\n") or card end end) end local members = {} pcall(function() members = sorted_members(craft.crew) end) local extra = 0 if #members > #crewRows then extra = #members - (#crewRows - 1) end for i, row in ipairs(crewRows) do local overflow = extra > 0 and i == #crewRows local entry = members[i] if overflow then if row.bar then row.bar.visible = false end if row.icon then row.icon.visible = false pcall(function() row.icon:hide() end) end set_caption(row.label, string.format("+%d more on the ship window", extra)) if row.label then row.label.tooltip = "The ship window lists the remaining crew stacks." row.label.visible = true end elseif not entry then show_row(row, false) else local crewman = entry.crewman local profession = crewman.profession local caption = profession_caption(crewman, entry.num) local tip = crew_tooltip(crewman, entry.num) if crewman.specialist then tip = attach_tracked(tip, profession_key(crewman.profession), tonumber(crewman.level) or 1, entry.num) end set_caption(row.label, string.format("%s x%d", caption, entry.num)) if row.label then row.label.tooltip = tip row.label.visible = true end if row.icon then pcall(function() row.icon.picture = profession.icon row.icon.color = profession.color row.icon.tooltip = tip row.icon.visible = true row.icon:show() end) end if row.bar then pcall(function() row.bar:setCrewmen(crewman, entry.num) row.bar.tooltip = tip row.bar.visible = true end) end end end end refreshShipCrew = AethelspaceHubPopup.refreshShipCrew showWindow = AethelspaceHubPopup.showWindow function AethelspaceHubPopup.onPromoteClicked() if not specialistCombo then return end local person = peopleChoices[specialistCombo.selectedIndex] if not person or person.id == "" then return end invokeServerFunction("promoteCrewman", person.id) end onPromoteClicked = AethelspaceHubPopup.onPromoteClicked function AethelspaceHubPopup.onAssignmentPicked() end onAssignmentPicked = AethelspaceHubPopup.onAssignmentPicked local function fill_assignment_controls() if controlsFilled or not specialistCombo then return end controlsFilled = true for _, person in ipairs(peopleChoices) do local label = string.format("%s (%s)", person.name, JOB_LABELS[person.key] or person.key) if person.elite then label = label .. " Elite" end specialistCombo:addEntry(label) end shipChoices = {} if shipCombo then for line in tostring(shipText or ""):gmatch("[^\n]+") do local id, name = line:match("^([^~]+)~(.*)$") if id and name then table.insert(shipChoices, { id = id, name = name }) shipCombo:addEntry(name) end end end end function AethelspaceHubPopup.onReassignClicked() if not specialistCombo or not jobCombo then return end local person = peopleChoices[specialistCombo.selectedIndex] local job = JOB_KEYS[(jobCombo.selectedIndex or 0) + 1] if not person or person.id == "" or not job then return end invokeServerFunction("reassignSpecialist", person.id, job) end onReassignClicked = AethelspaceHubPopup.onReassignClicked function AethelspaceHubPopup.onTransferClicked() if not specialistCombo or not shipCombo then return end local person = peopleChoices[specialistCombo.selectedIndex] local ship = shipChoices[shipCombo.selectedIndex] if not person or person.id == "" or not ship then return end invokeServerFunction("transferSpecialist", person.id, ship.id) end onTransferClicked = AethelspaceHubPopup.onTransferClicked function AethelspaceHubPopup.onDeliverFood() invokeServerFunction("deliverColonyGood", "Food Rations") end function AethelspaceHubPopup.onDeliverMeds() invokeServerFunction("deliverColonyGood", "Medical Supplies") end function AethelspaceHubPopup.onDeliverLux() invokeServerFunction("deliverColonyGood", "Luxury Goods") end function AethelspaceHubPopup.onAnchorElevator() invokeServerFunction("anchorSpaceElevator") end function AethelspaceHubPopup.onDepositSteel() invokeServerFunction("depositMegaMaterial", "Steel") end function AethelspaceHubPopup.onDepositProcessors() invokeServerFunction("depositMegaMaterial", "Processors") end function AethelspaceHubPopup.onDepositAll() invokeServerFunction("depositMegaMaterial", "ALL") end function AethelspaceHubPopup.onSupplyHull() invokeServerFunction("supplyShipyard", "Hull Parts") end function AethelspaceHubPopup.onSupplyElectronics() invokeServerFunction("supplyShipyard", "Advanced Electronics") end function AethelspaceHubPopup.onSupplyShields() invokeServerFunction("supplyShipyard", "Shield Components") end function AethelspaceHubPopup.onCommissionRing() invokeServerFunction("commissionMegastructure", "orbital_ring") end function AethelspaceHubPopup.onCommissionDyson() invokeServerFunction("commissionMegastructure", "dyson_swarm_node") end function AethelspaceHubPopup.onCommissionNexus() invokeServerFunction("commissionMegastructure", "jump_gate_nexus") end onDeliverFood = AethelspaceHubPopup.onDeliverFood onDeliverMeds = AethelspaceHubPopup.onDeliverMeds onDeliverLux = AethelspaceHubPopup.onDeliverLux onAnchorElevator = AethelspaceHubPopup.onAnchorElevator onDepositSteel = AethelspaceHubPopup.onDepositSteel onDepositProcessors = AethelspaceHubPopup.onDepositProcessors onDepositAll = AethelspaceHubPopup.onDepositAll onSupplyHull = AethelspaceHubPopup.onSupplyHull onSupplyElectronics = AethelspaceHubPopup.onSupplyElectronics onSupplyShields = AethelspaceHubPopup.onSupplyShields onCommissionRing = AethelspaceHubPopup.onCommissionRing onCommissionDyson = AethelspaceHubPopup.onCommissionDyson onCommissionNexus = AethelspaceHubPopup.onCommissionNexus function AethelspaceHubPopup.onSeat1() invokeServerFunction("seatOfficer", 1) end function AethelspaceHubPopup.onSeat2() invokeServerFunction("seatOfficer", 2) end function AethelspaceHubPopup.onSeat3() invokeServerFunction("seatOfficer", 3) end function AethelspaceHubPopup.onSeat4() invokeServerFunction("seatOfficer", 4) end function AethelspaceHubPopup.onSeat5() invokeServerFunction("seatOfficer", 5) end onSeat1 = AethelspaceHubPopup.onSeat1 onSeat2 = AethelspaceHubPopup.onSeat2 onSeat3 = AethelspaceHubPopup.onSeat3 onSeat4 = AethelspaceHubPopup.onSeat4 onSeat5 = AethelspaceHubPopup.onSeat5 function AethelspaceHubPopup.receiveHubData(data) if not data then return end set_caption(statusLabel, tostring(data.status_text or "Sector ledger: unavailable.")) set_caption(intelLabel, tostring(data.intel_text or "No sovereign structures in this sector.")) set_caption(megaLabel, tostring(data.mega_text or "No megastructure in this sector.")) trackedText = tostring(data.tracked_text or "") shipText = tostring(data.ship_text or "") -- The personnel tab is drawn from the ship. Named specialists are added on hover. pcall(AethelspaceHubPopup.refreshShipCrew) pcall(fill_assignment_controls) pcall(function() invokeServerFunction("noteAlive", "data-applied") end) end receiveHubData = AethelspaceHubPopup.receiveHubData function AethelspaceHubPopup.initialize() want_show = true seen = true pcall(function() invokeServerFunction("noteAlive", "popup-init") end) end initialize = AethelspaceHubPopup.initialize function AethelspaceHubPopup.hideWindow() if not window then return end pcall(function() window:hide() end) pcall(function() if Hud() then Hud():removeMouseShowingWindow(window) end end) end hideWindow = AethelspaceHubPopup.hideWindow local function hub_k_down() if not Keyboard or not KeyboardKey then return false end local kb = Keyboard() if not kb then return false end local key = KeyboardKey._K or KeyboardKey.K if key == nil then return false end local ok, pressed = pcall(function() return kb:keyPressed(key) end) return ok and (pressed == true or pressed == 1) end function AethelspaceHubPopup.updateClient() if want_show then local res = getResolution() if res and res.x > 200 and res.y > 200 then local ok, err = pcall(AethelspaceHubPopup.showWindow) if ok and window and window.visible then want_show = false elseif not ok then pcall(function() invokeServerFunction("noteAlive", "popup-show-failed " .. string.sub(tostring(err), 1, 160)) end) end end end local player = Player() if valid(player) and player:getValue("aethelspace_open_hub") then if not (window and window.visible) then pcall(AethelspaceHubPopup.showWindow) end end local down = false pcall(function() down = hub_k_down() end) if down and not k_was_down then if window and window.visible then AethelspaceHubPopup.hideWindow() else pcall(AethelspaceHubPopup.showWindow) end pcall(function() invokeServerFunction("noteAlive", "key-k") end) end k_was_down = down end updateClient = AethelspaceHubPopup.updateClient function AethelspaceHubPopup.restore(data) seen = data and data.seen or false end restore = AethelspaceHubPopup.restore function AethelspaceHubPopup.secure() return { seen = seen } end secure = AethelspaceHubPopup.secure end if onServer() then local profession_type_for local take_one_specialist local find_tracked_person function AethelspaceHubPopup.getUpdateInterval() return 0.5 end getUpdateInterval = AethelspaceHubPopup.getUpdateInterval function AethelspaceHubPopup.noteAlive(kind) local player = Player(callingPlayer) local name = valid(player) and player.name or "unknown" print(string.format("[Kdiag] %s %s", name, tostring(kind))) end callable(AethelspaceHubPopup, "noteAlive") function AethelspaceHubPopup.initialize() local player = Player() if valid(player) then print(string.format("[Kdiag] popup server init for %s", player.name)) end end initialize = AethelspaceHubPopup.initialize function AethelspaceHubPopup.updateServer() local player = Player() if not valid(player) then return end if not player:getValue("aethelspace_open_hub") then return end player:setValue("aethelspace_open_hub", nil) local ok, err = pcall(function() invokeClientFunction(player, "showWindow") end) if ok then print(string.format("[Kdiag] popup show sent to %s", player.name)) else print(string.format("[Kdiag] popup show failed for %s: %s", player.name, tostring(err))) end end updateServer = AethelspaceHubPopup.updateServer local function count_entries(t) local n = 0 if type(t) ~= "table" then return 0 end for _ in pairs(t) do n = n + 1 end return n end local function player_index_number(player) local direct = tonumber(player.index) if direct then return direct end return tonumber(tostring(player.index):match("(%d+)")) end local function player_owns_person(player, person) local oid = tostring(person.owner_entity_id or "") local idx = tostring(player.index) local num = player_index_number(player) if oid == idx or oid == tostring(player.name or "") or oid == ("player_" .. idx) then return true end if num and tonumber(oid) == num then return true end return false end local function load_hub_state() package.path = package.path .. ";data/scripts/lib/?.lua;data/scripts/?.lua" local json = include("json") if not json or not json.decode then error("json module missing") end local path = "/home/ark/servers/avorion/galaxies/difficult_roosters/aethelspace_macro_state.json" local f, err = io.open(path, "r") if not f then error("ledger open failed: " .. tostring(err)) end local content = f:read("*a") f:close() local decoded = json.decode(content) if type(decoded) ~= "table" then error("ledger decode returned no table") end return decoded end local function role_stars(person) local role = tostring(person.assigned_role or "") if role == "captain" then return tonumber(person.stat_piloting) or 0 end if role == "marine" then return tonumber(person.stat_boarding) or 0 end if role == "manager" then return tonumber(person.stat_management) or 0 end return tonumber(person.stat_engineering) or 0 end local function person_is_aboard(person) local aboard = person.aboard return aboard == true or aboard == 1 or aboard == "true" end local function tracked_text_for(state, player) local ship_id = "" pcall(function() if valid(player.craft) then ship_id = tostring(player.craft.id or "") end end) local lines = {} if ship_id == "" then return "" end for _, person in ipairs((state and state.personnel) or {}) do if player_owns_person(player, person) and tostring(person.record_source or "") == "ship_specialist" and person_is_aboard(person) and tostring(person.assigned_asset_id or "") == ship_id then table.insert(lines, string.format( "%s|%s|%s|%.2f|%s|%s|%s", tostring(person.personnel_id or ""), tostring(person.profession_key or ""), tostring(person.personnel_name or "Specialist"), role_stars(person), tostring(person.vanilla_level or 1), tostring(person.job_stars or ""), (person.elite_crew == true or person.elite_crew == 1 or person.elite_crew == "true") and "elite" or "" )) end end table.sort(lines) return table.concat(lines, "\n") end local function ship_text_for(state, player) local seen = {} local lines = {} for _, person in ipairs((state and state.personnel) or {}) do if player_owns_person(player, person) and tostring(person.record_source or "") == "ship_specialist" then local ship_id = tostring(person.assigned_asset_id or "") if ship_id ~= "" and not seen[ship_id] then seen[ship_id] = true table.insert(lines, ship_id .. "~" .. tostring(person.ship_name or ship_id)) end end end table.sort(lines) return table.concat(lines, "\n") end local function collect_roster(state, player) local roster = {} for _, person in ipairs((state and state.personnel) or {}) do if player_owns_person(player, person) then table.insert(roster, person) end end table.sort(roster, function(a, b) return tostring(a.personnel_name or "") < tostring(b.personnel_name or "") end) return roster end local function seated_captain_name(craft) local seated = nil pcall(function() seated = craft:getCaptain() end) if not seated then return nil end local name = seated.name or seated.displayName if not name or name == "" then return "the current captain" end return tostring(name) end local function seat_named_captain(player, officer_name) local craft = player.craft if not valid(craft) then return false, "You must be piloting a ship." end local already = seated_captain_name(craft) if already then return false, string.format("%s is already captain of %s. Remove that captain before seating another.", already, tostring(craft.name)) end local CaptainGenerator = include("captaingenerator") local cpt = nil pcall(function() cpt = CaptainGenerator():generate(1, 0, 0, 0) end) if not cpt then pcall(function() cpt = CaptainGenerator():generate() end) end if not cpt then return false, "The ship could not accept a captain." end cpt.name = officer_name local crew = craft.crew crew:setCaptain(cpt) craft.crew = crew return true, craft.name end function AethelspaceHubPopup.requestHubData() local player = Player(callingPlayer) or Player() if not valid(player) then return end local sector = Sector() local x, y = 0, 0 if sector then x, y = sector:getCoordinates() end local faction = Galaxy():getLocalFaction(x, y) local faction_name = faction and faction.name or "Uncharted" faction_name = tostring(faction_name):gsub("/%*.-%*/", ""):gsub("%s+$", "") local colony_name = "none in this sector" local population = "0" local food, meds, luxury = 0, 0, 0 local stock_line = "none" local roster = {} local mega_here = "No megastructure in this sector." local elev_here = "No space elevator in this sector." local galaxy_megas = "Galaxy megastructures: 0" local gate_count, gate_hull = 0, 0 local colony_count = 0 local state = nil local loaded_ok, loaded = pcall(load_hub_state) if loaded_ok then state = loaded else print("[Kdiag] ledger load error: " .. tostring(loaded)) end if state then colony_count = count_entries(state.colonies) local key = string.format("%d_%d", x, y) local col = (state.colonies or {})[key] if col then colony_name = string.format("%s (%s)", tostring(col.planet_name or "colony"), tostring(col.planet_type or "world")) population = tostring(col.population_billions or "0") local stock = col.planetary_stockpile or {} if type(stock) == "string" then pcall(function() local json = include("json") stock = json.decode(stock) or {} end) end if type(stock) == "table" then food = tonumber(stock["Food Rations"]) or 0 meds = tonumber(stock["Medical Supplies"]) or 0 luxury = tonumber(stock["Luxury Goods"]) or 0 local bits = {} for name, amount in pairs(stock) do local n = tonumber(amount) or 0 if n > 0 then table.insert(bits, string.format("%s %d", tostring(name), n)) end end table.sort(bits) if #bits > 0 then stock_line = table.concat(bits, " | ") end end end local mega = (state.megastructures or {})[key] if mega then mega_here = string.format("This sector: %s %s%% %s", tostring(mega.structure_type), tostring(mega.construction_progress_pct), tostring(mega.status or "")) end local elev = (state.elevators or {})[key] if elev then elev_here = string.format("This sector: %s", tostring(elev.station_name or elev.elevator_id)) end local mega_bits = {} for _, item in ipairs(state.all_megastructures or {}) do table.insert(mega_bits, string.format("%s at (%s, %s) %s%%", tostring(item.structure_type), tostring(item.x), tostring(item.y), tostring(item.construction_progress_pct))) end if #mega_bits > 0 then galaxy_megas = "Galaxy megastructures: " .. table.concat(mega_bits, "; ") end for _, gate in pairs(state.shipyard_gates or {}) do gate_count = gate_count + 1 gate_hull = gate_hull + (tonumber(gate.hull_parts_stored) or 0) end roster = collect_roster(state, player) end local roster_lines = {} for i, person in ipairs(roster) do if i > 5 then break end local post = tostring(person.assigned_asset_id or "") if post == "" then post = "unassigned" end table.insert(roster_lines, string.format( "%d %s %s %s P%s E%s M%s", i, tostring(person.personnel_name or "Officer"), tostring(person.assigned_role or "crew"), post, tostring(person.stat_piloting or "?"), tostring(person.stat_engineering or "?"), tostring(person.stat_management or "?") )) end local tracked_text = tracked_text_for(state, player) local ship_text = ship_text_for(state, player) local roster_text = "No named specialists on this ship yet. The census names specialists who are aboard. The captain is not replaced." if tracked_text ~= "" then roster_text = "Named specialists on this ship:\n" .. tracked_text:gsub("|", " ") end local class_names = { [0] = "None", [1] = "Commodore", [2] = "Smuggler", [3] = "Merchant", [4] = "Miner", [5] = "Scavenger", [6] = "Explorer", [7] = "Daredevil", [8] = "Scientist", [9] = "Hunter" } local captain_line = "Flagship: no ship" local hold_text = "no ship" pcall(function() local craft = player.craft if not valid(craft) then return end local seated = "no captain seated" local cpt = craft:getCaptain() if cpt and cpt.name and cpt.name ~= "" then local class_name = class_names[cpt.primaryClass] or "crew" seated = string.format("%s, %s", tostring(cpt.name), class_name) end local crew_n = 0 pcall(function() crew_n = craft.crew.size or 0 end) local perk_line = "" pcall(function() local CaptainUtility = include("captainutility") local classes = CaptainUtility.ClassProperties() local perk_info = CaptainUtility.PerkProperties() local primary = classes[cpt.primaryClass] local secondary = classes[cpt.secondaryClass] if primary and primary.displayName and primary.displayName ~= "" then seated = primary.displayName if secondary and secondary.displayName and secondary.displayName ~= "" then seated = seated .. " / " .. secondary.displayName end if cpt.name and cpt.name ~= "" then seated = tostring(cpt.name) .. ", " .. seated end end local perk_names = {} for _, perk in pairs({cpt:getPerks()}) do local info = perk_info[perk] if info and info.displayName and info.displayName ~= "" then table.insert(perk_names, info.displayName) end end if #perk_names > 0 then perk_line = "\nPerks: " .. table.concat(perk_names, ", ") end seated = string.format("%s\nVanilla tier %s, level %s", seated, tostring(cpt.tier), tostring(cpt.level)) end) captain_line = string.format("%s — %s\nVanilla crew aboard: %d. This captain is not replaced by the simulation.%s", tostring(craft.name), seated, crew_n, perk_line) local food_h, meds_h, lux_h, steel_h, proc_h, hull_h, elec_h, shield_h = 0, 0, 0, 0, 0, 0, 0, 0 for good, amount in pairs(craft:getCargos()) do local name = good.name if name == "Food Rations" then food_h = food_h + amount elseif name == "Medical Supplies" then meds_h = meds_h + amount elseif name == "Luxury Goods" then lux_h = lux_h + amount elseif name == "Steel" then steel_h = steel_h + amount elseif name == "Processor" or name == "Processors" then proc_h = proc_h + amount elseif name == "Hull Parts" or name == "Hull Part" then hull_h = hull_h + amount elseif name == "Advanced Electronics" then elec_h = elec_h + amount elseif name == "Shield Components" or name == "Shield Component" then shield_h = shield_h + amount end end hold_text = string.format( "Food %d | Meds %d | Luxury %d | Steel %d | Processors %d | Hull %d | Electronics %d | Shields %d", food_h, meds_h, lux_h, steel_h, proc_h, hull_h, elec_h, shield_h ) end) local status_text = string.format( "Sector (%d, %d)\nSovereign: %s\nColony: %s\nPopulation: %s\nColony stock: %s\nYour hold: %s\nColonies in the ledger: %d", x, y, faction_name, colony_name, population, stock_line, hold_text, colony_count ) local intel_text = string.format( "%s\n%s\n%s\nShipyard gates: %d\nHull parts stored on gates: %d", mega_here, elev_here, galaxy_megas, gate_count, gate_hull ) local mega_text = "No megastructure in this sector. Commission one below. Deposit Steel and Processors after it exists." local gate_here = "No shipyard gate in this sector." if state then local key = string.format("%d_%d", x, y) local mega = (state.megastructures or {})[key] local buf = mega and mega.resource_buffer_json or nil if type(buf) == "string" then pcall(function() local json = include("json") buf = json.decode(buf) end) end if type(buf) ~= "table" then buf = {} end if mega then mega_text = string.format( "This sector: %s\nStatus: %s\nProgress: %s%%\nBuffer: Steel %s | Processors %s", tostring(mega.structure_type or "project"), tostring(mega.status or "under_construction"), tostring(mega.construction_progress_pct or 0), tostring(buf["Steel"] or 0), tostring(buf["Processors"] or buf["Processor"] or 0) ) end local gate = (state.shipyard_gates or {})[key] if gate then gate_here = string.format( "Shipyard gate here: Hull %s | Electronics %s | Shields %s", tostring(gate.hull_parts_stored or 0), tostring(gate.advanced_electronics_stored or 0), tostring(gate.shield_components_stored or 0) ) end end mega_text = mega_text .. "\n" .. gate_here .. "\n\nSupply buttons sell parts to the gate in this sector. Commission refuses if a project is already here." print(string.format("[Kdiag] %s hub data requested index=%s roster=%d gates=%d", player.name, tostring(player.index), #roster, gate_count)) invokeClientFunction(player, "receiveHubData", { status_text = status_text, captain_line = captain_line, roster_text = roster_text, intel_text = intel_text, mega_text = mega_text, tracked_text = tracked_text, ship_text = ship_text }) end callable(AethelspaceHubPopup, "requestHubData") function AethelspaceHubPopup.seatOfficer(slot) local player = Player(callingPlayer) or Player() if not valid(player) then return end -- The vanilla captain keeps the chair, the class, and the perks. -- Seat is a label for the simulation roster until the two systems are merged. player:sendChatMessage("Fleet Command", ChatMessageType.Information, "The seated captain stays. Aethelspace lists simulation officers beside that captain and does not replace perks or quirks.") end callable(AethelspaceHubPopup, "seatOfficer") local CAPTAIN_CLASS_FOR_JOB = nil local CAPTAIN_CLASS_NAME = nil local function captain_class_tables() if CAPTAIN_CLASS_FOR_JOB then return end local CaptainUtility = include("captainutility") local C = CaptainUtility.ClassType local P = CaptainUtility.PerkType CAPTAIN_CLASS_FOR_JOB = { Engine = C.Scientist, Repair = C.Scientist, Gunner = C.Commodore, Miner = C.Miner, Pilot = C.Explorer, Security = C.Hunter, Attacker = C.Hunter, } CAPTAIN_CLASS_NAME = { [C.Commodore] = "Commodore", [C.Smuggler] = "Smuggler", [C.Merchant] = "Merchant", [C.Miner] = "Miner", [C.Scavenger] = "Scavenger", [C.Explorer] = "Explorer", [C.Daredevil] = "Daredevil", [C.Scientist] = "Scientist", [C.Hunter] = "Hunter", } AethelspaceHubPopup._perk = P AethelspaceHubPopup._class = C end local function stars_for_person(person) local stars = {} for _, job in ipairs(JOB_KEYS) do stars[job] = 0 end for token in tostring(person.job_stars or ""):gmatch("[^;]+") do local job, value = token:match("^([^=]+)=([%d%.]+)$") if job and stars[job] ~= nil then stars[job] = tonumber(value) or 0 end end local current = tostring(person.profession_key or "") if stars[current] == 0 then local legacy = tonumber(person.stat_piloting) or 0 if current == "Gunner" or current == "Security" or current == "Attacker" then legacy = tonumber(person.stat_boarding) or legacy elseif current ~= "Pilot" then legacy = tonumber(person.stat_engineering) or legacy end if legacy > (stars[current] or 0) then stars[current] = legacy end end return stars end local function captain_shape_from_stars(stars) captain_class_tables() local ranked = {} for _, job in ipairs(JOB_KEYS) do table.insert(ranked, { job = job, stars = stars[job] or 0 }) end table.sort(ranked, function(a, b) if a.stars == b.stars then return a.job < b.job end return a.stars > b.stars end) local best = ranked[1].stars local second = ranked[2].stars local tier, level = 1, 1 if best >= 4 and second >= 4 then tier, level = 3, 5 elseif best >= 4 and second >= 2 then tier, level = 3, 4 elseif best >= 4 then tier, level = 2, 4 elseif best >= 2 and second >= 2 then tier, level = 2, 3 elseif best >= 2 then tier, level = 1, 2 end local elite = true for _, job in ipairs(JOB_KEYS) do if (stars[job] or 0) < 5 then elite = false break end end if elite then tier, level = 3, 5 end local primary = CAPTAIN_CLASS_FOR_JOB[ranked[1].job] local secondary = nil if tier >= 3 then for i = 2, #ranked do local class = CAPTAIN_CLASS_FOR_JOB[ranked[i].job] if class and class ~= primary then secondary = class break end end if not secondary then tier = 2 end end return tier, level, primary, secondary, ranked, elite end local function captain_perks_for(gen, primary, secondary, ranked, tier) captain_class_tables() local P = AethelspaceHubPopup._perk local positive_for = { Engine = P.Educated, Repair = P.Humble, Gunner = P.Intimidating, Miner = P.Lucky, Pilot = P.Navigator, Security = P.Stealthy, Attacker = P.Intimidating, } local neutral_for = { Engine = P.Noble, Repair = P.Careful, Gunner = P.Reckless, Miner = P.Commoner, Pilot = P.Cunning, Security = P.Cunning, Attacker = P.Reckless, } local blocked = {} local function block_class(class) if not class or class == 0 then return end local pos, neg, neu = gen:getImpossiblePerksOfClass(class) for _, list in ipairs({ pos, neg, neu }) do for _, perk in pairs(list) do blocked[perk] = true end end end block_class(primary) block_class(secondary) local want_positive = (tier >= 3) and 3 or ((tier == 2) and 2 or 1) local perks = {} local seen = {} local function add_perk(perk) if not perk or blocked[perk] or seen[perk] then return false end seen[perk] = true table.insert(perks, perk) return true end for _, row in ipairs(ranked) do if #perks >= want_positive then break end add_perk(positive_for[row.job]) end for _, perk in ipairs({ P.Educated, P.Navigator, P.Lucky, P.Connected }) do if #perks >= want_positive then break end add_perk(perk) end for _, row in ipairs(ranked) do if add_perk(neutral_for[row.job]) then break end end return perks end function AethelspaceHubPopup.promoteCrewman(personnel_id) local player = Player(callingPlayer) or Player() if not valid(player) then return end local craft = player.craft if not valid(craft) or not craft.crew then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "You must be piloting a ship to promote a specialist.") return end local already = seated_captain_name(craft) if already then player:sendChatMessage("Fleet Command", ChatMessageType.Error, string.format("%s is already captain of %s. Remove that captain from the ship before promoting another.", already, craft.name)) return end if not personnel_id or personnel_id == "" then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Choose a named specialist first.") return end local state = nil pcall(function() state = load_hub_state() end) local person = find_tracked_person(state, player, personnel_id) if not person or tostring(person.record_source or "") ~= "ship_specialist" then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "That specialist is not on this ship.") return end if tostring(person.assigned_asset_id or "") ~= tostring(craft.id) then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Fly the ship that specialist is aboard.") return end local stars = stars_for_person(person) local tier, level, primary, secondary, ranked, elite = captain_shape_from_stars(stars) local old_job = tostring(person.profession_key or "") local old_level = tonumber(person.vanilla_level) or 1 local removed = take_one_specialist(craft, profession_type_for(old_job), old_level) if removed < 1 then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Could not find that specialist on the ship.") return end local seed = 0 local pid = tostring(personnel_id) for i = 1, #pid do seed = (seed + string.byte(pid, i) * i) % 1000003 end local CaptainGenerator = include("captaingenerator") local gen = CaptainGenerator(seed) local captain = gen:generate(tier, level, primary, secondary) if not captain then craft:addCrew(1, CrewMan(profession_type_for(old_job), true, old_level)) player:sendChatMessage("Fleet Command", ChatMessageType.Error, "The captain could not be built. The specialist is still aboard.") return end captain.name = tostring(person.personnel_name or "Captain") local perks = captain_perks_for(gen, primary, secondary, ranked, tier) pcall(function() captain:setPerks(perks) end) pcall(function() captain.salary = gen:calculateSalary(captain) end) local seated = false pcall(function() craft.crew:setCaptain(captain) seated = seated_captain_name(craft) == captain.name end) if not seated then craft:addCrew(1, CrewMan(profession_type_for(old_job), true, old_level)) player:sendChatMessage("Fleet Command", ChatMessageType.Error, "The chair did not accept that captain. The specialist is still aboard.") return end local x, y = 0, 0 pcall(function() x, y = player:getSectorCoordinates() end) local perk_names = {} pcall(function() local info = include("captainutility").PerkProperties() for _, perk in ipairs(perks) do local row = info[perk] local label = row and (row.displayName or "") or "" label = label:gsub("/%*.-%*/", ""):gsub("%s+$", "") if label ~= "" then table.insert(perk_names, label) end end end) local primary_name = CAPTAIN_CLASS_NAME[primary] or "Captain" local secondary_name = secondary and CAPTAIN_CLASS_NAME[secondary] or "" local class_line = primary_name if secondary_name ~= "" then class_line = class_line .. " / " .. secondary_name end local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("SpecialistPromotedToCaptain", x, y, player.name, tostring(craft.id), { personnel_id = pid, captain_name = captain.name, primary_class = primary_name, secondary_class = secondary_name, tier = tier, level = level, elite = elite and "1" or "0", perks = table.concat(perk_names, ", "), }) local elite_line = elite and " Elite Crew." or "" player:sendChatMessage("Fleet Command", ChatMessageType.ServerInfo, string.format( "%s takes the chair of %s as a tier %d level %d %s.%s Perks: %s.", captain.name, craft.name, tier, level, class_line, elite_line, (#perk_names > 0) and table.concat(perk_names, ", ") or "none" )) AethelspaceHubPopup.requestHubData() end callable(AethelspaceHubPopup, "promoteCrewman") profession_type_for = function(key) if key == "Engine" then return CrewProfessionType.Engine end if key == "Repair" then return CrewProfessionType.Repair end if key == "Gunner" then return CrewProfessionType.Gunner end if key == "Miner" then return CrewProfessionType.Miner end if key == "Pilot" then return CrewProfessionType.Pilot end if key == "Security" then return CrewProfessionType.Security end if key == "Attacker" then return CrewProfessionType.Attacker end return nil end local function level_for_job_stars(packed, key) for token in tostring(packed or ""):gmatch("[^;]+") do local job, value = token:match("^([^=]+)=([%d%.]+)$") if job == key then local stars = tonumber(value) or 0 if stars >= 4 then return 3 end if stars >= 2 then return 2 end return 1 end end return 1 end take_one_specialist = function(craft, profession, level) local target = nil for crewman, num in pairs(craft.crew:getMembers()) do local matched = false pcall(function() matched = crewman.specialist and crewman.profession and crewman.profession.value == profession and tonumber(crewman.level) == level and (tonumber(num) or 0) > 0 end) if matched then target = crewman break end end if not target then return 0 end for _, rank in pairs({ CrewRank.None, CrewRank.Sergeant, CrewRank.Lieutenant, CrewRank.Colonel }) do pcall(function() target.rank = rank end) local removed = craft:removeCrew(1, target) or 0 if removed > 0 then return removed end end return 0 end find_tracked_person = function(state, player, personnel_id) for _, person in ipairs((state and state.personnel) or {}) do if player_owns_person(player, person) and tostring(person.personnel_id or "") == tostring(personnel_id) then return person end end return nil end function AethelspaceHubPopup.reassignSpecialist(personnel_id, new_job) local player = Player(callingPlayer) or Player() if not valid(player) or not valid(player.craft) or not player.craft.crew then return end local profession = profession_type_for(new_job) if not profession then return end local state = load_hub_state() local person = find_tracked_person(state, player, personnel_id) if not person or tostring(person.record_source or "") ~= "ship_specialist" then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "That specialist is not on this ship.") return end if tostring(player.craft.id) ~= tostring(person.assigned_asset_id or "") then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Fly the ship that specialist is aboard before changing their job.") return end local old_job = tostring(person.profession_key or "") if old_job == new_job then player:sendChatMessage("Fleet Command", ChatMessageType.Information, person.personnel_name .. " is already in that job.") return end local old_level = tonumber(person.vanilla_level) or 1 local new_level = level_for_job_stars(person.job_stars, new_job) local removed = take_one_specialist(player.craft, profession_type_for(old_job), old_level) if removed < 1 then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Could not find that specialist stack on the ship.") return end local added = false local ok = pcall(function() player.craft:addCrew(1, CrewMan(profession, true, new_level)) added = true end) if not ok or not added then pcall(function() player.craft:addCrew(1, CrewMan(profession_type_for(old_job), true, old_level)) end) player:sendChatMessage("Fleet Command", ChatMessageType.Error, "The job change was rolled back.") return end local x, y = 0, 0 pcall(function() x, y = player:getSectorCoordinates() end) local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("SpecialistJobChange", x, y, player.name, tostring(player.craft.id), { personnel_id = tostring(personnel_id), new_profession = new_job, new_level = new_level, }) player:sendChatMessage("Fleet Command", ChatMessageType.Information, string.format( "%s is now a %s. Their other job stars stay with them.", tostring(person.personnel_name), JOB_LABELS[new_job] or new_job )) end callable(AethelspaceHubPopup, "reassignSpecialist") function AethelspaceHubPopup.transferSpecialist(personnel_id, target_ship_id) local player = Player(callingPlayer) or Player() if not valid(player) or not valid(player.craft) or not player.craft.crew then return end if not target_ship_id or target_ship_id == "" then return end local state = load_hub_state() local person = find_tracked_person(state, player, personnel_id) if not person or tostring(person.record_source or "") ~= "ship_specialist" then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "That specialist is not on this ship.") return end local source_id = tostring(person.assigned_asset_id or "") if tostring(player.craft.id) ~= source_id then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Fly the ship that specialist is aboard before moving them.") return end if tostring(target_ship_id) == source_id then player:sendChatMessage("Fleet Command", ChatMessageType.Information, person.personnel_name .. " is already aboard this ship.") return end local old_job = tostring(person.profession_key or "") local old_level = tonumber(person.vanilla_level) or 1 local removed = take_one_specialist(player.craft, profession_type_for(old_job), old_level) if removed < 1 then player:sendChatMessage("Fleet Command", ChatMessageType.Error, "Could not find that specialist stack on the ship.") return end local x, y = 0, 0 pcall(function() x, y = player:getSectorCoordinates() end) local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("SpecialistTransferRequest", x, y, player.name, source_id, { personnel_id = tostring(personnel_id), target_ship_id = tostring(target_ship_id), }) player:sendChatMessage("Fleet Command", ChatMessageType.Information, string.format( "Moving %s. The destination ship has to be flown by someone online.", tostring(person.personnel_name) )) end callable(AethelspaceHubPopup, "transferSpecialist") local COLONY_DELIVERIES = { ["Food Rations"] = { amount = 250, price = 85 }, ["Medical Supplies"] = { amount = 50, price = 220 }, ["Luxury Goods"] = { amount = 10, price = 850 }, } local SHIPYARD_DELIVERIES = { ["Hull Parts"] = { amount = 100, price = 1200, aliases = { "Hull Part" }, field = "hull_parts" }, ["Advanced Electronics"] = { amount = 25, price = 3500, aliases = {}, field = "advanced_electronics" }, ["Shield Components"] = { amount = 10, price = 7500, aliases = { "Shield Component" }, field = "shield_components" }, } local COMMISSION_COSTS = { orbital_ring = 5000000, dyson_swarm_node = 7500000, jump_gate_nexus = 10000000, } local function sector_key_now() local sector = Sector() local x, y = 0, 0 if sector then x, y = sector:getCoordinates() end return x, y, string.format("%d_%d", x, y) end local function ledger_now() local ok, state = pcall(load_hub_state) if not ok then return nil end return state end local function cargo_named(craft, names) local items = craft:getCargos() for good, amount in pairs(items) do for _, name in ipairs(names) do if good.name == name then return good, amount end end end return nil, 0 end function AethelspaceHubPopup.deliverColonyGood(good_name) local player = Player(callingPlayer) or Player() if not valid(player) or not valid(player.craft) then return end local spec = COLONY_DELIVERIES[tostring(good_name or "")] if not spec then return end local x, y, key = sector_key_now() local state = ledger_now() local col = state and (state.colonies or {})[key] if not col then player:sendChatMessage("Colony Logistics", ChatMessageType.Error, "No surface colony in this sector.") return end local good, have = cargo_named(player.craft, { good_name }) if not good or have < spec.amount then player:sendChatMessage("Colony Logistics", ChatMessageType.Warning, string.format( "Need %d %s in the hold. This ship has %d.", spec.amount, good_name, have)) return end player.craft:removeCargo(good, spec.amount) local payout = spec.amount * spec.price player:receive(payout) local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("SupplyPlanetaryColony", x, y, player.name, col.colony_id, { goods = { [good_name] = spec.amount }, player_name = player.name, payout = payout, }) player:sendChatMessage("Colony Logistics", ChatMessageType.Information, string.format( "Delivered %d %s to %s. Paid %s credits.", spec.amount, good_name, tostring(col.planet_name or "the colony"), createMonetaryString(payout))) AethelspaceHubPopup.requestHubData() end callable(AethelspaceHubPopup, "deliverColonyGood") function AethelspaceHubPopup.anchorSpaceElevator() local player = Player(callingPlayer) or Player() if not valid(player) then return end local x, y, key = sector_key_now() local state = ledger_now() local elev = state and (state.elevators or {})[key] if elev and (elev.is_active == true or elev.is_active == 1 or tostring(elev.is_active) == "true") then player:sendChatMessage("Orbital Logistics", ChatMessageType.Error, "This sector already has a space elevator.") return end local col = state and (state.colonies or {})[key] if not col then player:sendChatMessage("Orbital Logistics", ChatMessageType.Error, "No surface colony in this sector.") return end local faction = Faction(player.allianceIndex or player.index) if not faction or not faction:canPay(250000, 5000) then player:sendChatMessage("Orbital Logistics", ChatMessageType.Error, "Need 5,000 Iron and 250,000 credits.") return end local SectorGenerator = include("SectorGenerator") local anchor = nil local built, build_err = pcall(function() anchor = SectorGenerator(x, y):createStation(faction, "data/scripts/entity/merchants/equipmentdock.lua") end) if not built or not valid(anchor) then player:sendChatMessage("Orbital Logistics", ChatMessageType.Error, "The elevator station could not be built. Nothing was charged.") print("[Kdiag] elevator build failed " .. tostring(build_err)) return end faction:pay("Constructed Orbital Space Elevator Tether"%_T, 250000, 5000) anchor.name = string.format("%s Orbital Elevator Terminal", faction.name) anchor:setValue("aethelspace_macro_managed", true) anchor:setValue("aethelspace_space_elevator_anchor", true) local plan = BlockPlan() local mat = Material(MaterialType.Iron) local white = ColorRGB(0.85, 0.85, 0.9) for i = -30, 30 do plan:addBlock(vec3(0, i * 20, 0), vec3(16, 20, 16), plan.rootIndex, -1, white, mat, Matrix(), BlockType.Hull, ColorNone()) end pcall(function() anchor:setMovePlan(plan) end) local elev_id = string.format("ELEV_%d_%d_%s", x, y, tostring(faction.index)) local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("DeploySpaceElevator", x, y, tostring(player.index), tostring(anchor.index), { elevator_id = elev_id, sector_id = string.format("sec_%d_%d", x, y), linked_station_id = tostring(anchor.index), station_name = anchor.name, faction_id = tostring(faction.index), }) player:sendChatMessage("Orbital Logistics", ChatMessageType.Information, "Space elevator anchored. The ledger will show it on the next refresh.") AethelspaceHubPopup.requestHubData() end callable(AethelspaceHubPopup, "anchorSpaceElevator") function AethelspaceHubPopup.depositMegaMaterial(material_type) local player = Player(callingPlayer) or Player() if not valid(player) or not valid(player.craft) then return end local kind = tostring(material_type or "") if kind ~= "Steel" and kind ~= "Processors" and kind ~= "ALL" then return end local x, y, key = sector_key_now() local state = ledger_now() local mega = state and (state.megastructures or {})[key] if not mega then player:sendChatMessage("Megastructure Grid", ChatMessageType.Error, "No megastructure project in this sector.") return end local craft = player.craft local stacks = {} for good, amount in pairs(craft:getCargos()) do table.insert(stacks, { good = good, amount = amount }) end local dep_steel, dep_proc = 0, 0 for _, stack in ipairs(stacks) do local good, amount = stack.good, stack.amount if amount > 0 and (kind == "Steel" or kind == "ALL") and good.name == "Steel" then local take = (kind == "ALL") and amount or math.min(amount, 500) craft:removeCargo(good, take) dep_steel = dep_steel + take elseif amount > 0 and (kind == "Processors" or kind == "ALL") and (good.name == "Processor" or good.name == "Processors") then local take = (kind == "ALL") and amount or math.min(amount, 50) craft:removeCargo(good, take) dep_proc = dep_proc + take end end if dep_steel == 0 and dep_proc == 0 then player:sendChatMessage("Megastructure Grid", ChatMessageType.Warning, "The hold has none of that material.") return end local AethelTelemetry = include("aethelspace_telemetry") if dep_steel > 0 then AethelTelemetry.send("DepositMegastructureMaterial", x, y, player.name, mega.megastructure_id, { megastructure_id = mega.megastructure_id, material = "Steel", amount = dep_steel, }) end if dep_proc > 0 then AethelTelemetry.send("DepositMegastructureMaterial", x, y, player.name, mega.megastructure_id, { megastructure_id = mega.megastructure_id, material = "Processors", amount = dep_proc, }) end player:sendChatMessage("Megastructure Grid", ChatMessageType.Information, string.format( "Deposited %d Steel and %d Processors.", dep_steel, dep_proc)) AethelspaceHubPopup.requestHubData() end callable(AethelspaceHubPopup, "depositMegaMaterial") function AethelspaceHubPopup.supplyShipyard(good_name) local player = Player(callingPlayer) or Player() if not valid(player) or not valid(player.craft) then return end local spec = SHIPYARD_DELIVERIES[tostring(good_name or "")] if not spec then return end local x, y, key = sector_key_now() local state = ledger_now() local gate = state and (state.shipyard_gates or {})[key] if not gate then player:sendChatMessage("Shipyard Logistics", ChatMessageType.Error, "No shipyard gate in this sector.") return end local names = { good_name } for _, alias in ipairs(spec.aliases) do table.insert(names, alias) end local good, have = cargo_named(player.craft, names) if not good or have < spec.amount then player:sendChatMessage("Shipyard Logistics", ChatMessageType.Warning, string.format( "Need %d %s in the hold. This ship has %d.", spec.amount, good_name, have)) return end player.craft:removeCargo(good, spec.amount) local payout = spec.amount * spec.price player:receive(payout) local payload = { station_id = gate.station_id, hull_parts = 0, advanced_electronics = 0, shield_components = 0 } payload[spec.field] = spec.amount local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("DeliverShipyardComponents", x, y, player.name, gate.station_id, payload) player:sendChatMessage("Shipyard Logistics", ChatMessageType.Information, string.format( "Supplied %d %s. Paid %s credits.", spec.amount, good_name, createMonetaryString(payout))) AethelspaceHubPopup.requestHubData() end callable(AethelspaceHubPopup, "supplyShipyard") function AethelspaceHubPopup.commissionMegastructure(structure_type) local player = Player(callingPlayer) or Player() if not valid(player) then return end local stype = tostring(structure_type or "") local cost = COMMISSION_COSTS[stype] if not cost then return end local x, y, key = sector_key_now() local state = ledger_now() if state and (state.megastructures or {})[key] then player:sendChatMessage("Megastructure Grid", ChatMessageType.Error, "This sector already has a megastructure project.") return end local faction = Faction(player.allianceIndex or player.index) if not faction or not faction:canPay(cost) then player:sendChatMessage("Megastructure Grid", ChatMessageType.Error, string.format( "Need %s credits to commission that project.", createMonetaryString(cost))) return end faction:pay("Commissioned Alliance Megastructure Project"%_T, cost) local AethelTelemetry = include("aethelspace_telemetry") AethelTelemetry.send("CommissionMegastructure", x, y, player.name, stype, { structure_type = stype, owner_id = faction.name, }) player:sendChatMessage("Megastructure Grid", ChatMessageType.Information, string.format( "Commissioned %s in this sector.", stype)) AethelspaceHubPopup.requestHubData() end callable(AethelspaceHubPopup, "commissionMegastructure") end