From e775016aad5c88b7c5c9c3992a05f0bca86c992d Mon Sep 17 00:00:00 2001 From: Claudius Mueller Date: Mon, 10 Apr 2023 22:16:52 -0600 Subject: [PATCH] crew life initial commit --- data/lang/module-crewcontracts/en.json | 106 ++++- data/lang/ui-core/en.json | 40 +- data/modules/CrewContracts/CrewContracts.lua | 226 ++++------ data/modules/CrewContracts/CrewLife.lua | 426 +++++++++++++++++++ data/pigui/modules/info-view/05-crew.lua | 162 +++++-- 5 files changed, 785 insertions(+), 175 deletions(-) create mode 100644 data/modules/CrewContracts/CrewLife.lua diff --git a/data/lang/module-crewcontracts/en.json b/data/lang/module-crewcontracts/en.json index 5f528ebb42b..95ca6552e8b 100644 --- a/data/lang/module-crewcontracts/en.json +++ b/data/lang/module-crewcontracts/en.json @@ -1,7 +1,7 @@ { - "ASK_CANDIDATE_TO_SIT_A_TEST": { + "ASK_CANDIDATE_FOR_INTERVIEW_AND_TEST": { "description": "", - "message": "Ask candidate to sit a test" + "message": "Ask candidate for interview and test" }, "CREWDETAILSHEETBB": { "description": "", @@ -13,7 +13,7 @@ }, "CREWTESTRESULTSBB": { "description": "", - "message": "Examination results:\n\nGeneral crew competence: {general}%\nEngineering and repair: {engineering}%\nPiloting and spaceflight: {piloting}%\nNavigation and plotting: {navigation}%\nSensors and defence: {sensors}%\nOverall exam score: {overall}%" + "message": "Interview Impressions\n\nLawfulness: {lawfulness_impression}\nAffinity for civilization: {civaffinity_impression}\n\nTest Results\n\nGeneral crew competence: {general}%\nEngineering and repair: {engineering}%\nPiloting and spaceflight: {piloting}%\nNavigation and plotting: {navigation}%\nSensors and defence: {sensors}%\nOverall exam score: {overall}%" }, "CREW_FOR_HIRE": { "description": "", @@ -27,10 +27,26 @@ "description": "", "message": "CREW FOR HIRE" }, + "DOESNT_NEED_FREQUENT_CULTURE": { + "description": "", + "message": "doesn't need frequent cultural and social interactions" + }, + "FOLLOWS_LAW_TO_THE_LETTER": { + "description": "", + "message": "follows the law to the letter" + }, "GO_BACK": { "description": "", "message": "Go back" }, + "I_WILL_SEND_SOMEONE_AFTER_YOU": { + "description": "", + "message": "And I'll send some friends after you to collect my outstanding pay!" + }, + "I_WOULD_NEVER_CONSIDER_WORKING_FOR_YOU": { + "description": "", + "message": "Are you joking? I would never consider working for you!" + }, "IM_SORRY_IM_NOT_PREPARED_TO_GO_ANY_LOWER": { "description": "", "message": "I'm sorry, I'm not prepared to go any lower." @@ -39,6 +55,18 @@ "description": "", "message": "I'm sorry, your offer isn't attractive to me." }, + "LAW_ABIDING_CITIZEN": { + "description": "", + "message": "generally law abiding citizen" + }, + "LAWS_ARE_FOR_OTHERS": { + "description": "", + "message": "laws are for others" + }, + "LOVES_CULTURE": { + "description": "", + "message": "loves culture and diverse social interactions" + }, "MAKE_OFFER_OF_POSITION_ON_SHIP_FOR_STATED_AMOUNT": { "description": "", "message": "Make offer of position on ship for stated amount" @@ -55,6 +83,10 @@ "description": "", "message": "No experience" }, + "NOT_PRO_OR_CONTRA_LAW": { + "description": "", + "message": "not particularly pro or contra law" + }, "OK_I_SUPPOSE_THATS_ALL_RIGHT": { "description": "", "message": "OK, I suppose that's all right." @@ -63,6 +95,10 @@ "description": "", "message": "Potential crew members are registered as seeking employment at {station}:" }, + "SICK_OF_WORKING_FOR_YOU": { + "description": "", + "message": "I'm sick of working for you! I'm going to look for employment elsewhere and I'll make sure to spread the word about you around here. Good luck finding new crew!" + }, "SIMULATOR_TRAINING_ONLY": { "description": "", "message": "Simulator training only" @@ -95,8 +131,72 @@ "description": "", "message": "Time served crew member" }, + "THOUGHT_EMPLOYMENT": { + "description": "", + "message": "found employment aboard a ship" + }, + "THOUGHT_HAPPY_HOME": { + "description": "", + "message": "happy to be home again" + }, + "THOUGHT_ILLEGAL_TRADING_BAD": { + "description": "", + "message": "complicit in illegal trade" + }, + "THOUGHT_ILLEGAL_TRADING_GOOD": { + "description": "", + "message": "excited about illegal trade" + }, + "THOUGHT_OFFENDER": { + "description": "", + "message": "working for an offender" + }, + "THOUGHT_CRIMINAL": { + "description": "", + "message": "working for a criminal" + }, + "THOUGHT_OUTLAW": { + "description": "", + "message": "working for an outlaw" + }, + "THOUGHT_FUGITIVE": { + "description": "", + "message": "working for a fugitive" + }, + "THOUGHT_TOO_MANY_BUSY_SYSTEMS": { + "description": "", + "message": "visiting busy systems too much" + }, + "THOUGHT_TOO_FEW_BUSY_SYSTEMS": { + "description": "", + "message": "visiting not enough busy sytems" + }, + "THOUGHT_WELL_DEVELOPED_SYSTEMS": { + "description": "", + "message": "visiting well-developed systems" + }, + "THOUGHT_QUIET_SYSTEMS": { + "description": "", + "message": "visiting nice and quiet systems" + }, + "THOUGHT_EXPLORING_THE_FRONTIER": { + "description": "", + "message": "exploring the frontier" + }, + "THOUGHT_DULL_FRONTIER": { + "description": "", + "message": "wasting my time in empty systems" + }, "VETERAN_TIME_SERVED_CREW_MEMBER": { "description": "", "message": "Veteran, time served crew member" + }, + "WANTS_TO_GET_AWAY_FROM_CIVILIZATION": { + "description": "", + "message": "wants to get away from civilization" + }, + "WILL_DO_WHAT_THEY_WANT": { + "description": "", + "message": "will do whatever they can get away with it" } } diff --git a/data/lang/ui-core/en.json b/data/lang/ui-core/en.json index 2750a328c2e..b15bc17c641 100644 --- a/data/lang/ui-core/en.json +++ b/data/lang/ui-core/en.json @@ -159,6 +159,10 @@ "description": "Paintshop button", "message": "Change Pattern" }, + "CHARISMA": { + "description": "", + "message": "Charisma" + }, "CHIEF_MECHANIC": { "description": "", "message": "Chief Mechanic" @@ -445,7 +449,7 @@ }, "ENGINEERING": { "description": "Engineering skills of crew", - "message": "Engineering:" + "message": "Engineering" }, "ENTRY": { "description": "A note/entry/record into the flight log", @@ -667,6 +671,10 @@ "description": "", "message": "Hang up." }, + "HAPPINESS": { + "description": "", + "message": "Happiness" + }, "HARMLESS": { "description": "Player combat rating", "message": "Harmless" @@ -1155,6 +1163,10 @@ "description": "", "message": "Insufficient funds." }, + "INTELLIGENCE": { + "description": "", + "message": "Intelligence" + }, "INTERSTELLAR_TRADE_AVG": { "description": "Label displaying commodity flow at a per-system level", "message": "Interstellar Trade:" @@ -1231,6 +1243,10 @@ "description": "", "message": "Permission granted. Watch for traffic on your departure." }, + "LAWFULNESS": { + "description": "", + "message": "Lawfulness" + }, "LEGAL_STATUS": { "description": "", "message": "Legal status" @@ -1303,6 +1319,10 @@ "description": "", "message": "Low" }, + "LUCK": { + "description": "", + "message": "Luck" + }, "LY": { "description": "Light year", "message": "ly" @@ -1461,7 +1481,7 @@ }, "NAVIGATION": { "description": "", - "message": "Navigation:" + "message": "Navigation" }, "NAVIGATOR": { "description": "", @@ -1507,6 +1527,10 @@ "description": "", "message": "Notes:" }, + "NOTORIETY": { + "description": "", + "message": "Notoriety" + }, "NOT_ENOUGH_ALLOY_TO_ATTEMPT_A_REPAIR": { "description": "", "message": "Not enough {alloy} to attempt a repair" @@ -1673,7 +1697,7 @@ }, "PILOTING": { "description": "", - "message": "Piloting:" + "message": "Piloting" }, "PILOT_SEAT_IS_NOW_OCCUPIED_BY_NAME": { "description": "", @@ -1779,6 +1803,10 @@ "description": "", "message": "Registration number" }, + "RELATIONSHIP_WITH_CAPTAIN": { + "description": "", + "message": "Relationship with Captain" + }, "RELIABLE": { "description": "For player reputation", "message": "Reliable" @@ -1905,7 +1933,7 @@ }, "SENSORS": { "description": "", - "message": "Sensors:" + "message": "Sensors" }, "SENSORS_AND_DEFENCE": { "description": "", @@ -2051,6 +2079,10 @@ "description": "", "message": "Station tech level too low to sell this item" }, + "STATS": { + "description": "", + "message": "Stats" + }, "STATUS": { "description": "", "message": "Status" diff --git a/data/modules/CrewContracts/CrewContracts.lua b/data/modules/CrewContracts/CrewContracts.lua index 344c7973e8e..7d5a177bb7f 100644 --- a/data/modules/CrewContracts/CrewContracts.lua +++ b/data/modules/CrewContracts/CrewContracts.lua @@ -8,11 +8,13 @@ local Engine = require 'Engine' local Game = require 'Game' local Character = require 'Character' local Format = require 'Format' -local Timer = require 'Timer' local utils = require 'utils' +local Rand = require 'Rand' + +local rand = Rand.New() -- This module allows the player to hire crew members through BB adverts --- on stations, and handles periodic events such as their wages. +-- on stations local l = Lang.GetResource("module-crewcontracts") local lui = Lang.GetResource("ui-core") @@ -29,111 +31,10 @@ local wage_period = 604800 -- a week of seconds -- outstanding = 0, -- } ----------------------- Part 1 ---------------------- --- Life aboard ship - -local boostCrewSkills = function (crewMember) - -- Each week, there's a small chance that a crew member gets better - -- at each skill, due to the experience of working on the ship. - - -- If they fail their intelligence roll, they learn nothing. - if not crewMember:TestRoll('intelligence') then return end - - -- The attributes to be tested and possibly enhanced. These will be sorted - -- by their current value, but appear first in arbitrary order. - local attribute = { - {'engineering',crewMember.engineering}, - {'piloting',crewMember.piloting}, - {'navigation',crewMember.navigation}, - {'sensors',crewMember.sensors}, - } - table.sort(attribute,function (a,b) return a[2] > b[2] end) - -- The sorted attributes mean that the highest scoring attribute gets the - -- first opportunity for improvement. The next loop actually makes it harder - -- for the highest scoring attributes to improve, so if they fail, the next - -- one is given an opportunity. At most one attribute will be improved, and - -- the distribution means that, for example, a pilot will improve in piloting - -- first, but once that starts to get very good, the other skills will start - -- to see an improvement. - - for i = 1,#attribute do - -- A carefully weighted test here. Scores will creep up to the low 50s. - if not crewMember:TestRoll(attribute[i][1],math.floor(attribute[i][2] * 0.2 - 10)) then - -- They learned from their failure, - crewMember[attribute[i][1]] = crewMember[attribute[i][1]]+1 - -- but only on this skill. - break - end - end -end - -local scheduleWages = function (crewMember) - -- Must have a contract to be treated like crew - if not crewMember.contract then return end - - local payWages - payWages = function () - local contract = crewMember.contract - -- Check if crew member has been dismissed - if not contract then return end - - if Game.player:GetMoney() > contract.wage then - Game.player:AddMoney(0 - contract.wage) - -- Being paid can make awkward crew like you more - if not crewMember:TestRoll('playerRelationship') then - crewMember.playerRelationship = crewMember.playerRelationship + 1 - end - else - contract.outstanding = contract.outstanding + contract.wage - crewMember.playerRelationship = crewMember.playerRelationship - 1 - Character.persistent.player.reputation = Character.persistent.player.reputation - 0.5 - end - - -- Attempt to pay off any arrears - local arrears = math.min(Game.player:GetMoney(),contract.outstanding) - Game.player:AddMoney(0 - arrears) - contract.outstanding = contract.outstanding - arrears - - -- The crew gain experience each week, and might get better - boostCrewSkills(crewMember) - - -- Schedule the next pay day, if there is one. - if contract.payday and not crewMember.dead then - contract.payday = contract.payday + wage_period - Timer:CallAt(math.max(Game.time + 5,contract.payday),payWages) - end - end - - Timer:CallAt(math.max(Game.time + 1,crewMember.contract.payday),payWages) -end - --- This gets run just after crew are restored from a saved game -Event.Register('crewAvailable',function() - -- scheduleWages() for everybody - for crewMember in Game.player:EachCrewMember() do - scheduleWages(crewMember) - end -end) - --- This gets run whenever a crew member joins a ship -Event.Register('onJoinCrew',function(ship, crewMember) - if ship:IsPlayer() then - scheduleWages(crewMember) - end -end) - --- This gets run whenever a crew member leaves a ship -Event.Register('onLeaveCrew',function(ship, crewMember) - if ship:IsPlayer() and crewMember.contract then - -- Prepare them for the job market - crewMember.estimatedWage = crewMember.contract.wage + 5 - -- Terminate their contract - crewMember.contract = nil - end -end) - ----------------------- Part 2 ---------------------- --- The bulletin board +-- New Character attribute: affinity for civilization +-- This determines how much the character enjoys being in busy systems with high +-- population vs. the unexplored frontier +local civaffinity = {"low", "medium", "high"} local nonPersistentCharactersForCrew = {} local stationsWithAdverts = {} @@ -182,6 +83,8 @@ local onChat = function (form,ref,option) -- Base wage on experience c.estimatedWage = c.estimatedWage or wageFromScore(c.experience) c.estimatedWage = utils.round(c.estimatedWage, 1) + -- pick affinity for civilization + c.civaffinity = civaffinity[rand:Integer(1, 3)] end -- Now look for any persistent characters that are available in this station @@ -208,6 +111,10 @@ local onChat = function (form,ref,option) -- (which should only happen if this candidate was dismissed with wages owing) c.estimatedWage = math.max(c.contract and (c.contract.wage + 5) or 0, c.estimatedWage or wageFromScore(c.experience)) c.estimatedWage = utils.round(c.estimatedWage, 1) + -- pick affinity for civilization if it doesn't exist + if not c.civaffinity then + c.civaffinity = civaffinity[rand:Integer(1, 3)] + end end form:ClearFace() @@ -228,33 +135,40 @@ local onChat = function (form,ref,option) end local showCandidateDetails = function (response) - local experience = - candidate.experience > 160 and l.VETERAN_TIME_SERVED_CREW_MEMBER or - candidate.experience > 140 and l.TIME_SERVED_CREW_MEMBER or - candidate.experience > 100 and l.MINIMAL_TIME_SERVED_ABOARD_SHIP or - candidate.experience > 60 and l.SOME_EXPERIENCE_IN_CONTROLLED_ENVIRONMENTS or - candidate.experience > 10 and l.SIMULATOR_TRAINING_ONLY or - l.NO_EXPERIENCE form:SetFace(candidate) form:Clear() form:SetTitle(candidate.name) - candidate:PrintStats() - print("Attitude: ",candidate.playerRelationship) - print("Aspiration: ",candidate.estimatedWage) - if response == "" then response = "\r" end - form:SetMessage(l.CREWDETAILSHEETBB:interp({ - name = candidate.name, - experience = experience, - wage = Format.Money(offer), - response = response, - })) - form:AddOption(l.MAKE_OFFER_OF_POSITION_ON_SHIP_FOR_STATED_AMOUNT,1) - form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer+10))}),2) - form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer+5))}),3) - form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer-5))}),4) - form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer-10))}),5) - form:AddOption(l.ASK_CANDIDATE_TO_SIT_A_TEST,6) - form:AddOption(l.GO_BACK, 0) + + -- if playerRelationship is terrible then don't even interact with player + if candidate.playerRelationship < 15 then + form:SetMessage(l.I_WOULD_NEVER_CONSIDER_WORKING_FOR_YOU) + form:AddOption(l.GO_BACK, 0) + else + local experience = + candidate.experience > 160 and l.VETERAN_TIME_SERVED_CREW_MEMBER or + candidate.experience > 140 and l.TIME_SERVED_CREW_MEMBER or + candidate.experience > 100 and l.MINIMAL_TIME_SERVED_ABOARD_SHIP or + candidate.experience > 60 and l.SOME_EXPERIENCE_IN_CONTROLLED_ENVIRONMENTS or + candidate.experience > 10 and l.SIMULATOR_TRAINING_ONLY or + l.NO_EXPERIENCE + candidate:PrintStats() + print("Attitude: ",candidate.playerRelationship) + print("Aspiration: ",candidate.estimatedWage) + if response == "" then response = "\r" end + form:SetMessage(l.CREWDETAILSHEETBB:interp({ + name = candidate.name, + experience = experience, + wage = Format.Money(offer), + response = response, + })) + form:AddOption(l.MAKE_OFFER_OF_POSITION_ON_SHIP_FOR_STATED_AMOUNT,1) + form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer+10))}),2) + form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer+5))}),3) + form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer-5))}),4) + form:AddOption(l.SUGGEST_NEW_WEEKLY_WAGE_OF_N:interp({newAmount=Format.Money(checkOffer(offer-10))}),5) + form:AddOption(l.ASK_CANDIDATE_FOR_INTERVIEW_AND_TEST,6) + form:AddOption(l.GO_BACK, 0) + end end if option > 0 then @@ -272,6 +186,7 @@ local onChat = function (form,ref,option) -- Offer of employment form:Clear() form:SetTitle(candidate.name) + if candidate:TestRoll('playerRelationship',15) then -- Boosting roll by 15, because they want to work if Game.player:Enroll(candidate) then @@ -303,7 +218,12 @@ local onChat = function (form,ref,option) candidate.playerRelationship = candidate.playerRelationship + 2 offer = checkOffer(offer + 10) candidate.estimatedWage = offer -- They'll now re-evaluate themself - showCandidateDetails(l.THATS_EXTREMELY_GENEROUS_OF_YOU) + + if candidate.playerRelationship < 15 then + showCandidateDetails(l.I_WOULD_NEVER_CONSIDER_WORKING_FOR_YOU) + else + showCandidateDetails(l.THATS_EXTREMELY_GENEROUS_OF_YOU) + end end if option == 3 then @@ -311,7 +231,11 @@ local onChat = function (form,ref,option) candidate.playerRelationship = candidate.playerRelationship + 1 offer = checkOffer(offer + 5) candidate.estimatedWage = offer -- They'll now re-evaluate themself - showCandidateDetails(l.THAT_CERTAINLY_MAKES_THIS_OFFER_LOOK_BETTER) + if candidate.playerRelationship < 15 then + showCandidateDetails(l.I_WOULD_NEVER_CONSIDER_WORKING_FOR_YOU) + else + showCandidateDetails(l.THAT_CERTAINLY_MAKES_THIS_OFFER_LOOK_BETTER) + end end if option == 4 then @@ -328,6 +252,11 @@ local onChat = function (form,ref,option) if option == 5 then -- Player suggested lowering the offer with $10 candidate.playerRelationship = candidate.playerRelationship - 2 + if candidate.playerRelationship < 15 then + showCandidateDetails(l.I_WOULD_NEVER_CONSIDER_WORKING_FOR_YOU) + else + showCandidateDetails(l.THATS_EXTREMELY_GENEROUS_OF_YOU) + end if candidate:TestRoll('playerRelationship') then offer = checkOffer(offer - 10) showCandidateDetails(l.OK_I_SUPPOSE_THATS_ALL_RIGHT) @@ -340,24 +269,41 @@ local onChat = function (form,ref,option) -- Player asks candidate to perform a test form:Clear() form:SetTitle(candidate.name) - local general,engineering,piloting,navigation,sensors = 0,0,0,0,0 + local general,engineering,piloting,navigation,sensors,lawfulness = 0,0,0,0,0,0 for i = 1,10 do if candidate:TestRoll('intelligence') then general = general + 10 end if candidate:TestRoll('engineering') then engineering = engineering + 10 end if candidate:TestRoll('piloting') then piloting = piloting + 10 end if candidate:TestRoll('navigation') then navigation = navigation + 10 end if candidate:TestRoll('sensors') then sensors = sensors + 10 end + if candidate:TestRoll('lawfulness') then lawfulness = lawfulness + 10 end end -- Candidates hate being tested. candidate.playerRelationship = candidate.playerRelationship - 1 -- Show results + + local lawfulness_impression = "" + if lawfulness > 90 then lawfulness_impression = l.FOLLOWS_LAW_TO_THE_LETTER + elseif lawfulness > 70 then lawfulness_impression = l.LAW_ABIDING_CITIZEN + elseif lawfulness > 40 then lawfulness_impression = l.NOT_PRO_OR_CONTRA_LAW + elseif lawfulness > 10 then lawfulness_impression = l.WILL_DO_WHAT_THEY_WANT + else lawfulness_impression = l.LAWS_ARE_FOR_OTHERS end + + local civaffinity_impression = "" + if candidate.civaffinity == "high" then civaffinity_impression = l.LOVES_CULTURE + elseif candidate.civaffinity == "medium" then civaffinity_impression = l.DOESNT_NEED_FREQUENT_CULTURE + elseif candidate.civaffinity == "low" then civaffinity_impression = l.WANTS_TO_GET_AWAY_FROM_CIVILIZATION + end + form:SetMessage(l.CREWTESTRESULTSBB:interp{ - general = general, - engineering = engineering, - piloting = piloting, - navigation = navigation, - sensors = sensors, - overall = math.ceil((general+general+engineering+piloting+navigation+sensors)/6), + lawfulness_impression = lawfulness_impression, + civaffinity_impression = civaffinity_impression, + general = general, + engineering = engineering, + piloting = piloting, + navigation = navigation, + sensors = sensors, + overall = math.ceil((general+general+engineering+piloting+navigation+sensors)/6), }) form:AddOption(l.GO_BACK, 7) end diff --git a/data/modules/CrewContracts/CrewLife.lua b/data/modules/CrewContracts/CrewLife.lua new file mode 100644 index 00000000000..7c596c19f09 --- /dev/null +++ b/data/modules/CrewContracts/CrewLife.lua @@ -0,0 +1,426 @@ +-- Copyright © 2013 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + + +-- This sub-module describes the crew life aboard the ship + +local Event = require 'Event' +local Comms = require 'Comms' +local Game = require 'Game' +local Timer = require 'Timer' +local Character = require 'Character' +local Lang = require 'Lang' +local Rand = require 'Rand' +local FlightLog = require 'FlightLog' + +local rand = Rand.New() +local l = Lang.GetResource("module-crewcontracts") + +local week_in_secs = 604800 -- a week of seconds +local month_in_secs = 2629800 -- month in seconds + + +-- thought = {text = "short thought description", +-- adjustment = signed integer that adjusts the playerRelationship, +-- time = Game.time when thought was applied} +-- memories = {} -- an ordered list of thoughts +-- civaffinity = ("high" | "medium" | "low") + +local max_memory = 4 -- maximum number of thoughts retained +local max_relationship = 65 -- maximum playerRelationship score +local min_relationship = 4 -- minimum playerRelationship score +local desert_threshold = 15 -- playerRelationship threshold at which crew members start deserting the ship +local law_upper_threshold = 45 -- lawfulness threshold at which lawfull crew can be unhappy with illegal trading +local law_lower_threshold = 25 -- lawfulness threshold at which lawless crew can be happy with illegal trading +local civ_high_threshold = 0.4 -- rolling mean visited system population threshold (high) for crew civ affinity happiness testing +local civ_low_threshold = 0.1 -- rolling mean visited system population threshold (low) for crew civ affinity happiness testing +local explored_threshold = 2 -- systems explored within the last 5 systems visited for triggering happiness impact +local decay_threshold = month_in_secs -- time in seconds after which thoughts start to drop from memory (plus up to a week) +local max_repeat_memory = 2 -- maximum time the same thought can exist in memory (avoids same-thought spamming) + + +local mean = function (x) + -- Return the mean of the values in x. Assumes values are numeric. + local sum = 0 + for _, value in pairs(x) do + sum = sum + value + end + return sum / #x +end + + +local thoughts = { + employment = {text = l.THOUGHT_EMPLOYMENT, adjustment = 10, time = 0}, + happy_home = {text = l.THOUGHT_HAPPY_HOME, adjustment = 1, time = 0}, + illegal_trading_bad = {text = l.THOUGHT_ILLEGAL_TRADING_BAD, adjustment = -1, time = 0}, + illegal_trading_good = {text = l.THOUGHT_ILLEGAL_TRADING_GOOD, adjustment = 1, time = 0}, + offender_bad = {text = l.THOUGHT_OFFENDER, adjustment = -1, time = 0}, + offender_good = {text = l.THOUGHT_OFFENDER, adjustment = 1, time = 0}, + criminal_bad = {text = l.THOUGHT_CRIMINAL, adjustment = -2, time = 0}, + ciminal_good = {text = l.THOUGHT_CRIMINAL, adjustment = 2, time = 0}, + outlaw_bad = {text = l.THOUGHT_OUTLAW, adjustment = -3, time = 0}, + outlaw_good = {text = l.THOUGHT_OUTLAW, adjustment = 3, time = 0}, + fugitive_bad = {text = l.THOUGHT_FUGITIVE, adjustment = -4, time = 0}, + fugitve_good = {text = l.THOUGHT_FUGITIVE, adjustment = 4, time = 0}, + high_civ_good = {text = l.THOUGHT_WELL_DEVELOPED_SYSTEMS, adjustment = 1, time = 0}, + high_civ_bad = {text = l.THOUGHT_TOO_MANY_BUSY_SYSTEMS, adjustment = -1, time = 0}, + low_civ_good = {text = l.THOUGHT_QUIET_SYSTEMS, adjustment = 1, time = 0}, + low_civ_bad = {text = l.THOUGHT_TOO_FEW_BUSY_SYSTEMS, adjustment = -1, time = 0}, + frontier_good = {text = l.THOUGHT_EXPLORING_THE_FRONTIER, adjustment = 2, time = 0}, + frontier_bad = {text = l.THOUGHT_DULL_FRONTIER, adjustment = -2, time = 0} +} + + +local boostCrewSkills = function (crewMember) + -- Each week, there's a small chance that a crew member gets better + -- at each skill, due to the experience of working on the ship. + + -- If they fail their intelligence roll, they learn nothing. + if not crewMember:TestRoll('intelligence') then return end + + -- The attributes to be tested and possibly enhanced. These will be sorted + -- by their current value, but appear first in arbitrary order. + local attribute = { + {'engineering',crewMember.engineering}, + {'piloting',crewMember.piloting}, + {'navigation',crewMember.navigation}, + {'sensors',crewMember.sensors}, + } + table.sort(attribute,function (a,b) return a[2] > b[2] end) + -- The sorted attributes mean that the highest scoring attribute gets the + -- first opportunity for improvement. The next loop actually makes it harder + -- for the highest scoring attributes to improve, so if they fail, the next + -- one is given an opportunity. At most one attribute will be improved, and + -- the distribution means that, for example, a pilot will improve in piloting + -- first, but once that starts to get very good, the other skills will start + -- to see an improvement. + + for i = 1,#attribute do + -- A carefully weighted test here. Scores will creep up to the low 50s. + if not crewMember:TestRoll(attribute[i][1],math.floor(attribute[i][2] * 0.2 - 10)) then + -- They learned from their failure, + crewMember[attribute[i][1]] = crewMember[attribute[i][1]]+1 + -- but only on this skill. + break + end + end +end + + +local scheduleWages = function (crewMember) + -- Must have a contract to be treated like crew + if not crewMember.contract then return end + + local payWages + payWages = function () + local contract = crewMember.contract + -- Check if crew member has been dismissed + if not contract then return end + + if Game.player:GetMoney() > contract.wage then + Game.player:AddMoney(0 - contract.wage) + -- Being paid can make awkward crew like you more + if not crewMember:TestRoll('playerRelationship') then + crewMember.playerRelationship = crewMember.playerRelationship + 1 + end + else + contract.outstanding = contract.outstanding + contract.wage + crewMember.playerRelationship = crewMember.playerRelationship - 1 + Character.persistent.player.reputation = Character.persistent.player.reputation - 0.5 + end + + -- Attempt to pay off any arrears + local arrears = math.min(Game.player:GetMoney(),contract.outstanding) + Game.player:AddMoney(0 - arrears) + contract.outstanding = contract.outstanding - arrears + + -- The crew gain experience each week, and might get better + boostCrewSkills(crewMember) + + -- Schedule the next pay day, if there is one. + if contract.payday and not crewMember.dead then + contract.payday = contract.payday + week_in_secs + Timer:CallAt(math.max(Game.time + 5,contract.payday),payWages) + end + end + + Timer:CallAt(math.max(Game.time + 1,crewMember.contract.payday),payWages) +end + + +-- Applies the supplied thought to the supplied crew member. This will commit the thought to their memory +-- (if the memory is not full) and make appropriate adjustments to the playerRelationship variable. +local applyThought = function (crewMember, thought) + + -- apply only to non-player crew members + if crewMember.player then return end + + -- avoid same-memory spamming + local same_memories = 0 + for _, memory in pairs(crewMember.memories) do + if memory.text == thought.text then + same_memories = same_memories + 1 + end + end + if same_memories >= max_repeat_memory then return end + + -- adjust relationship with player + crewMember.playerRelationship = crewMember.playerRelationship + thought.adjustment + if crewMember.playerRelationship > max_relationship then crewMember.playerRelationship = max_relationship end + if crewMember.playerRelationship < min_relationship then crewMember.playerRelationship = min_relationship end + + -- add thought to thought stack, respecting thought memory max + if not crewMember.memories then crewMember.memories = {} end + thought.time = Game.time + if #crewMember.memories == max_memory then table.remove(crewMember.memories, 1) end + table.insert(crewMember.memories, thought) +end + + +-- Drop thoughts from memory if they are older than the decay threshold +-- Gets run every week +local decayThoughts = function () + for crewMember in Game.player:EachCrewMember() do + if not crewMember.player then + for i, thought in pairs(crewMember.memories) do + if thought.time < Game.time - decay_threshold then + table.remove(crewMember.memories, i) + end + end + end + end +end + + +-- Deserts a crew member from the ship. Happens after docking to a space port. +local desertCrew = function(crewMember) + if Game.player:Dismiss(crewMember) then + + crewMember:Save() -- Save to persistent characters list + + Comms.Message(l.SICK_OF_WORKING_FOR_YOU, crewMember.name) + + if crewMember.contract.outstanding > 0 then + Comms.Message(l.I_WILL_SEND_SOMEONE_AFTER_YOU, crewMember.name) + end + end +end + +-- This gets run just after crew are restored from a saved game +local crewAvailable = function () + -- scheduleWages() for everybody + for crewMember in Game.player:EachCrewMember() do + if not crewMember.player then + scheduleWages(crewMember) + + -- for old saves compatibility, add empty crew memory bank if none exists + if not crewMember.memories then + crewMember.memories = {} + end + end + end +end + + +-- This gets run whenever the ship cargo changes +local onPlayerCargoChanged = function (comm, amount) + if not Game.system:IsCommodityLegal(comm.name) then + for crewMember in Game.player:EachCrewMember() do + if not crewMember.player then + + -- crew happy or upset about illegal goods (depending on lawfulness) + if crewMember.lawfulness > law_upper_threshold and crewMember:TestRoll('lawfulness') then + applyThought(crewMember, thoughts['illegal_trading_bad']) + elseif crewMember.lawfulness < law_lower_threshold and not crewMember:TestRoll('lawfulness') then + applyThought(crewMember, thoughts['illegal_trading_good']) + end + end + end + end +end + + +-- This gets run whenever a crew member joins a ship +local onJoinCrew = function (ship, crewMember) + if ship:IsPlayer() then + scheduleWages(crewMember) + + -- start with blank memory stack + crewMember.memories = {} + + -- happy because of employment + applyThought(crewMember, thoughts['employment']) + + -- start tracking visits to home + crewMember.homeStation = ship:GetDockedWith().path + crewMember.lastHomeVisit = Game.time + end +end + + +-- This gets run whenever a crew member leaves a ship +local onLeaveCrew = function (ship, crewMember) + if ship:IsPlayer() and crewMember.contract then + -- Prepare them for the job market + crewMember.estimatedWage = crewMember.contract.wage + 5 + -- Terminate their contract + crewMember.contract = nil + + -- clean up custom variables + crewMember.homeStation = nil + crewMember.lastHomeVisit = nil + end +end + + +local onShipDocked = function (ship, station) + if not ship:IsPlayer() then return end + + for crewMember in Game.player:EachCrewMember() do + if not crewMember.player then + + -- check for deserting crew members at each station dock + if crewMember.playerRelationship < desert_threshold then + if not crewMember:TestRoll('playerRelationship') then + desertCrew(crewMember) + end + end + + -- good thought if visiting home system of the crew member + -- assumes that the last saved location for this character + -- is actually it's "home" + if station.path == crewMember.homeStation then + -- only triggers if last visit is more than a month ago + if Game.time - month_in_secs > crewMember.lastHomeVisit then + applyThought(crewMember, thoughts["happy_home"]) + end + crewMember.lastHomeVisit = Game.time + end + end + end +end + + +local onEnterSystem = function (ship) + if not ship:IsPlayer() then return end + + for crewMember in Game.player:EachCrewMember() do + if not crewMember.player then + + -- collect some information about the last 5 systems visited + local pops = {} + local explored = {} + for path, _, _, _ in FlightLog.GetSystemPaths(5) do + table.insert(pops, path:GetStarSystem().population) + if path:GetStarSystem().explored then + table.insert(explored, 1) + else + table.insert(explored, 0) + end + end + + local randint = rand:Integer(1, 5) + -- check for player legal status effect on crew happiness + if randint == 1 then + local status = Game.player:GetLegalStatus() + if crewMember.lawfulness > law_upper_threshold and crewMember:TestRoll('lawfulness') then + if status == 'OFFENDER' then applyThought(crewMember, thoughts['offender_bad']) + elseif status == 'CRIMINAL' then applyThought(crewMember, thoughts['criminal_bad']) + elseif status == 'OUTLAW' then applyThought(crewMember, thoughts['outlaw_bad']) + elseif status == 'FUGITIVE' then applyThought(crewMember, thoughts['fugitive_bad']) + end + elseif crewMember.lawfulness < law_lower_threshold and not crewMember:TestRoll('lawfulness') then + if status == 'OFFENDER' then applyThought(crewMember, thoughts['offender_good']) + elseif status == 'CRIMINAL' then applyThought(crewMember, thoughts['criminal_good']) + elseif status == 'OUTLAW' then applyThought(crewMember, thoughts['outlaw_good']) + elseif status == 'FUGITIVE' then applyThought(crewMember, thoughts['fugitive_good']) + end + end + + -- check for system population effect on crew happiness + elseif randint == 2 then + local mean_pops = mean(pops) + if crewMember.civaffinity == 'high' then + if mean_pops > civ_high_threshold then applyThought(crewMember, thoughts['high_civ_good']) + elseif mean_pops < civ_low_threshold then applyThought(crewMember, thoughts['low_civ_bad']) + end + elseif crewMember.civaffinity == 'low' then + if mean_pops > civ_high_threshold then applyThought(crewMember, thoughts['high_civ_bad']) + elseif mean_pops < civ_low_threshold then applyThought(crewMember, thoughts['low_civ_good']) + end + end + + -- check for system exploration status on crew happiness + elseif randint == 3 then + local num_explored = 0 + for _, value in pairs(explored) do + num_explored = num_explored + value + end + if crewMember.cifaffinity == 'high' and num_explored > explored_threshold then + applyThought(crewMember, thoughts['frontier_bad']) + elseif crewMember.civaffinity == 'low' and num_explored > explored_threshold then + applyThought(crewMember, thoughts['frontier_good']) + end + end + end + end +end + + +-- debug only +local thoughtGenerator = function() + print("=====thoughtGenerator triggered") + + for crewMember in Game.player:EachCrewMember() do + if not crewMember.player then + + crewMember.playerRelationship = 0 + + -- local randint = rand:Integer(1, 18) + -- print("Thoughttest") + -- print(randint) + -- local thought_keys = {} + -- for key,_ in pairs(thoughts) do + -- table.insert(thought_keys, key) + -- end + -- local thought = thoughts[thought_keys[randint]] + -- print(thought.text) + -- applyThought(crewMember, thought) + + -- applyThought(crewMember, thoughts['negative']) + -- local randint = rand:Integer(0, 1) + -- local thought = {} + -- if randint == 1 then + -- thought = thoughts['positive'] + -- else + -- thought = thoughts['negative'] + -- end + -- applyThought(crewMember, thought) + + -- Comms.ImportantMessage("another thought") + end + end +end +-- + + + +local onGameStart = function () + Game.player:GetComponent('CargoManager'):AddListener('crewlife', onPlayerCargoChanged) + Timer:CallEvery(week_in_secs, decayThoughts) + + -- debug only + -- Timer:CallEvery(10, thoughtGenerator) + -- +end + + +Event.Register("onGameStart", onGameStart) +Event.Register("onShipDocked", onShipDocked) +Event.Register("onLeaveCrew", onLeaveCrew) +Event.Register("onJoinCrew", onJoinCrew) +Event.Register("crewAvailable", crewAvailable) +Event.Register("onEnterSystem", onEnterSystem) + + +-- Serializer:Register('CrewLife',serialize,unserialize) diff --git a/data/pigui/modules/info-view/05-crew.lua b/data/pigui/modules/info-view/05-crew.lua index fe8b156c76f..3cf982b1b9d 100644 --- a/data/pigui/modules/info-view/05-crew.lua +++ b/data/pigui/modules/info-view/05-crew.lua @@ -9,6 +9,9 @@ local ShipDef = require 'ShipDef' local InfoView = require 'pigui.views.info-view' local PiGuiFace = require 'pigui.libs.face' local Commodities = require 'Commodities' +local StationView = require 'pigui.views.station-view' +local Vector2 = _G.Vector2 + local ui = require 'pigui' local textTable = require 'pigui.libs.text-table' @@ -226,46 +229,149 @@ local function drawCrewList(crewList) ui.text(lastTaskResult) end + +-- wrapper around gaugees, for consistent size, and vertical spacing +-- (taken from 03-econ-trade.lua) +local function gauge_bar(x, text, min, max, icon) + local height = ui.getTextLineHeightWithSpacing() + local cursorPos = ui.getCursorScreenPos() + local fudge_factor = 1.0 + local gaugeWidth = ui.getContentRegion().x * fudge_factor + local gaugePos = Vector2(cursorPos.x, cursorPos.y + height * 0.5) + + ui.gauge(gaugePos, x, '', text, min, max, icon, + colors.gaugeEquipmentMarket, '', gaugeWidth, height) + + -- ui.addRect(cursorPos, cursorPos + Vector2(gaugeWidth, height), colors.gaugeCargo, 0, 0, 1) + ui.dummy(Vector2(gaugeWidth, height)) +end + + +local function drawQualifications(crewMember) + ui.withFont(orbiteer.body, function() ui.text(l.QUALIFICATION_SCORES) end) + gauge_bar(crewMember.engineering, l.ENGINEERING, 4, 65, icons.personal) + gauge_bar(crewMember.piloting, l.PILOTING, 4, 65, icons.personal) + gauge_bar(crewMember.navigation, l.NAVIGATION, 4, 65, icons.personal) + gauge_bar(crewMember.sensors, l.SENSORS, 4, 65, icons.personal) +end + + +local function drawStats(crewMember) + ui.withFont(orbiteer.body, function() ui.text(l.STATS) end) + gauge_bar(crewMember.luck, l.LUCK, 4, 65, icons.personal) + gauge_bar(crewMember.intelligence, l.INTELLIGENCE, 4, 65, icons.personal) + gauge_bar(crewMember.charisma, l.CHARISMA, 4, 65, icons.personal) + gauge_bar(crewMember.lawfulness, l.LAWFULNESS, 4, 65, icons.personal) +end + + +local function drawReputation(crewMember) + ui.withFont(orbiteer.body, function() ui.text(l.REPUTATION) end) + gauge_bar(crewMember.notoriety, l.NOTORIETY, 4, 65, icons.personal) + textTable.draw({ + { l.RATING, l[crewMember:GetCombatRating()] }, + { l.KILLS, ui.Format.Number(crewMember.killcount) }, + { l.REPUTATION..":",l[crewMember:GetReputationRating()] }, + }) +end + + +local function drawHappiness(crewMember) + ui.withFont(orbiteer.body, function() ui.text(l.HAPPINESS) end) + gauge_bar(crewMember.playerRelationship, l.RELATIONSHIP_WITH_CAPTAIN, 4, 65, icons.personal) + + -- TODO: move the following to top of script + local PiImage = require 'pigui.libs.image' + local upIcon = PiImage.New("icons/market/export-major.png") + local downIcon = PiImage.New("icons/market/import-major.png") + local iconSize = Vector2(0, ui.getLineHeight()) + -- + + -- TODO: don't hardcode (also place spacing/info_column_width somewhere else?) + local child_height = 120 + -- local spacing = InfoView.windowPadding.x * 2.0 + -- local info_column_width = (ui.getColumnWidth() - spacing) / 2 + + ui.child("thoughts", Vector2(ui.getColumnWidth(), child_height), function() + ui.columns(2, 'memories', false) + ui.setColumnWidth(0, 50) + -- TODO: avoid hard-coding this width + ui.setColumnWidth(1, 500) + + -- TODO: place this check upstream (crew creation, loading?) + if not crewMember.memories then crewMember.memories = {} end + + for i, thought in pairs(crewMember.memories) do + if thought.adjustment < 0 then + downIcon:Draw(iconSize) + ui.nextColumn() + ui.textColored(colors.econLoss, thought.text) + else + upIcon:Draw(iconSize) + ui.nextColumn() + ui.textColored(colors.econProfit, thought.text) + end + ui.nextColumn() + end + end) +end + + +local function drawActions(crewMember) + ui.withFont(orbiteer.body, function() ui.text(l.EMPLOYMENT) end) + + if Game.player.flightState == 'DOCKED' then + if ui.button(l.DISMISS, Vector2(0, 0)) then dismissButton(crewMember) end + end + + if false then -- TODO: implement me! + ui.sameLine() + if ui.button(l.NEGOTIATE, Vector2(0, 0)) then openNegotiateWindow() end + end +end + + local crewFace = nil -local function drawCrewInfo(crew) - if not crewFace or crewFace.character ~= crew then - crewFace = PiGuiFace.New(crew) +local function drawCrewInfo(crewMember) + if not crewFace or crewFace.character ~= crewMember then + crewFace = PiGuiFace.New(crewMember) end local spacing = InfoView.windowPadding.x * 2.0 local info_column_width = (ui.getColumnWidth() - spacing) / 2 + ui.child("PlayerInfoDetails", Vector2(info_column_width, 0), function() - ui.withFont(orbiteer.heading, function() ui.text(crew.name) end) + ui.withFont(orbiteer.heading, function() ui.text(crewMember.name) end) ui.newLine() - textTable.withHeading(l.QUALIFICATION_SCORES, orbiteer.body, { - { l.ENGINEERING, crew.engineering }, - { l.PILOTING, crew.piloting }, - { l.NAVIGATION, crew.navigation }, - { l.SENSORS, crew.sensors }, - }) - ui.newLine() - textTable.withHeading(l.REPUTATION, orbiteer.body, { - { l.RATING, l[crew:GetCombatRating()] }, - { l.KILLS, ui.Format.Number(crew.killcount) }, - { l.REPUTATION..":",l[crew:GetReputationRating()] }, - }) + -- local child_height = ui.getContentRegion().y - StationView.style.height + -- TODO: don't hardcode + local child_height = 200 + + ui.child("qualifications", Vector2(info_column_width/2, child_height), function() + drawQualifications(crewMember) + end) - if not crew.player then - ui.newLine() - ui.withFont(orbiteer.body, function() ui.text(l.EMPLOYMENT) end) + ui.sameLine(0, spacing) - if Game.player.flightState == 'DOCKED' then - if ui.button(l.DISMISS, Vector2(0, 0)) then dismissButton(crew) end - end + ui.child("stats", Vector2(info_column_width/2, child_height), function() + drawStats(crewMember) + end) - if false then -- TODO: implement me! - ui.sameLine() - if ui.button(l.NEGOTIATE, Vector2(0, 0)) then openNegotiateWindow() end - end - end + ui.child("reputation", Vector2(info_column_width/2, child_height), function() + drawReputation(crewMember) + end) + + ui.sameLine(0, spacing) + + ui.child("happiness", Vector2(info_column_width/2, child_height), function() + drawHappiness(crewMember) + end) + + ui.child("PlayerInfoActions", Vector2(info_column_width, child_height), function() + drawActions(crewMember) + end) - ui.newLine() if ui.button(lcrew.GO_BACK, Vector2(0, 0)) then inspectingCrewMember = nil end end)