Gameplay content

Jobs and university

Majors and subjects as data, and the calls scenes use for jobs, school, rent and salaries.

University majors: .ulmajors

The majors a student can pick, and the subjects taught under each, are a data file found anywhere in any mod:

lua-- ynl_majors.ulmajors
return {
	{ NAME = "Hospitality", SUBJECTS = { "Mixology", "Hotel Management", "Event Planning", "Wine Studies" } },
	{ NAME = "History", SUBJECTS = { "Roman History", "Medieval History", "Modern History", "Art History" } },
}
  • A major with a NAME already defined by an earlier mod is replaced; a new name is added.
  • The order of SUBJECTS matters: lecturers are assigned subjects in that order (Actor:setSubject()).
  • Scenes show them through Actor:GetToken("Major"): the subject a lecturer teaches, or the major a student studies.

The base list lives in ul_Base/Majors.ulmajors.

The player's job and studies

Call Effect
Game:moveJobs() The player is hired at the building they are in; set the salary and boss separately
Game:getSalary(), Game:setSalary(n) The player's salary, a monthly amount in dollars. The game pays it on payday by itself, on the job's schedule: see Pay and payday
Game:loseJob() Fired: salary and workplace cleared, boss and colleagues become acquaintances
Game:startMayor() Become mayor (the job change after winning the election)
Game:moveSchools(), Game:isStudent() Enrol at the building they are in
Game:chooseMajor() then Game:setMajor() Let the player pick a major (a built-in choice menu)
Game:setTuition(n), Game:setFraternityFees(n), Game:moveFraternity() Student costs and fraternity
Game:quitSchool(), quitMedical(), quitLecturing(), quitFraternity(), quitCrime() Leave those careers
Game:getRent(), Game:setRent(n), Game:moveHome(), Game:loseHome() Housing
Game:imprison(), Game:endPrison() Prison (the sentence is the player's sentence stat, set by the scene)

Other people's jobs

Call Effect
Actor:setJob("Doctor"), Actor:getJob(), Actor:isJob("Nurse") A free-text job title
Actor:setWorkHere() They now work at the building the player is in
Actor:getBuilding("Work"), Actor:isAt("Work") Where they work, and whether they are there now
Actor:setBoss(), Actor:addColleague(), Actor:removeColleague() Workplace relationships with the player
Actor:setSameMajor(), Actor:setDifferentMajor(), Actor:setSubject() University roles
Actor:addProstitute(), Actor:removeProstitute(), Actor:isProstitute() Sex work
Actor:addCriminal(), Actor:setCrimeBoss() Organised crime

Job titles are free text: nothing validates them, so a mod can invent new ones and find those people with Game:getSpecific("Bartender").

Reading the job: Work:snapshot()

A mod that keeps progress for "this job" (a portfolio, a rank, a reputation at work) reads the job through Work:snapshot() instead of comparing titles:

lualocal job = Work:snapshot()          -- nil when the player has no job
if job then
	-- job.title, job.salary (monthly dollars), job.workplace (venue tag)
	-- job.building: the employer's own building id ("" when the job was not taken at one)
	-- job.place: its name; job.atWork: the player is there now
	-- job.roleVersion: changes with any new title or employer, even with the same title
	-- job.since: when it last changed; job.boss (person id) and job.bossAtWork (their routine)
end
  • Key what the player earns in a role to job.roleVersion, not to the title: moving to another office as "Analyst" is a new employer, and the version says so.
  • moveJobs makes the building the player is in the employer, so do it there (an interview at the venue, then the offer).
  • Events:on(mod, "jobChanged", function(what, roleVersion) end) runs when the title, the workplace or the salary changes, or the job is lost ("title", "workplace", "salary", "lost").
  • The salary is paid by the game: see Pay and payday.

Several jobs, hours and attendance: Jobs

The player can hold several jobs at once. The main job is still the one setJob, moveJobs, setSalary and loseJob manage (it is Jobs:get("main")); a mod adds side jobs with Jobs:add, which never touch it. Use a side job for anything that is not the player's career: a bar job in the evenings, a weekend shift, a gig.

Every job has days and hours. Attendance is measured, not declared: the time the player spends at the job's building during a shift. Below half the shift it is a missed shift:

  • missed shifts earn nothing: the main job's monthly salary is paid only for the shifts worked (a full week pays the whole salary), a side job is paid after each worked shift;
  • two missed shifts within 30 days bring a warning, the third the sack (the player is notified, and the job is gone);
  • overlapping jobs are allowed: the player chooses where to be, and the shift they skip is missed. Jobs:add returns the jobs that clash, so a hiring scene can say so.

Titles that are not jobs (Unemployed, Deceased, On remand...) and a job with nowhere to be are not tracked.

Hours are mod data: Jobs/schedules.json

json{
 "default": { "days": [1, 2, 3, 4, 5], "hours": [9, 17] },
 "no_schedule": ["Unemployed", "Deceased", "On remand"],
 "by_title": { "Nurse": { "days": [1, 3, 5, 6], "hours": [7, 19] } },
 "by_workplace": { "bar": { "days": [3, 4, 5, 6, 0], "hours": [18, 2] } },
 "rules": { "warn_after": 2, "fire_after": 3, "window_days": 30, "low_performance": 20, "performance_after_shifts": 5, "attendance_share": 0.5 }
}

Days are 0 (Sunday) to 6; an end hour at or before the start runs past midnight. A job takes its title's entry, else its workplace tag's, else default. A mod overrides any of these by shipping its own Jobs/schedules.json; a side job can also bring its own days and hours in Jobs:add.

Events:on(mod, "jobChanged", fn(what, id)) also reports "added", "worked", "missed", "warning", "fired", "quit" with the job id ("main" for the main job).

The job catalogue: Jobs/*.uljobs

A catalogue job is one with a ladder: what each rank needs from the knowledge tree, what it pays, and what working it teaches. It is what lets a hospital refuse a doctor who never studied. The catalogue is mod data, merged by ID in load order (a later mod with the same ID replaces the job):

lua-- ynl_Nightlife/Jobs/bar.uljobs
return {
	{ ID = "ynl_bartender", NAME = "Bartender", FIELD = "food", WORKPLACES = { "bar", "nightclub" },
	  LEARNS = { "ynl_bar_work", "ynl_mixology" },
	  RANKS = {
		{ TITLE = "Barback", SALARY = 1400 },
		{ TITLE = "Bartender", SALARY = 2100, MONTHS = 3,
		  REQUIRES = { KNOWLEDGE = { "ynl_bar_work:50" } } },
		{ TITLE = "Head bartender", SALARY = 3200, MONTHS = 12,
		  REQUIRES = { KNOWLEDGE = { "ynl_mixology:50" }, QUALIFICATIONS = { "ynl_Nightlife/door_licence" },
		               STATS = { interpersonal = 50, jobperformance = 60 } } },
	  } },
}
Field Meaning
ID Unique across mods: prefix yours with your mod's name.
NAME, FIELD Shown on the Knowledge screen; FIELD is a field of the tree. NAME defaults to the ID.
WORKPLACES Venue tags where it is offered (Jobs:catalogue("bar") lists the jobs of a bar).
LEARNS Knowledge nodes every worked shift adds progress to: a quarter of the shift's hours to each.
ALIASES Optional: other titles that mean this job, the ones the NPC simulation writes ("Office worker", "Care worker"). An NPC with one is placed on the ladder at rank 1 or 2.
RANKS[].TITLE What the job is called at that rank. It is what setJob writes, so Jobs/schedules.json, payroll and every isJob("Bartender") keep working. Defaults to NAME.
SALARY Monthly dollars before the city's cost of living; the game scales it.
MONTHS Tenure in the previous rank before this one can be reached.
REQUIRES KNOWLEDGE as "node" (a score of 25) or "node:50" (a score out of 100), QUALIFICATIONS as full "mod/id", STATS as minimum values. Cumulative: a rank's requirements are on top of every rank below it.

A job with no RANKS is a one-rank ladder: its NAME as the title, with SALARY and REQUIRES given on the job itself.

Legacy titles land on the ladder. The main job remembers its catalogue id. When a scene uses the old Player:setJob("Bartender") or an old save is loaded, the title is matched to a rank's TITLE (case-insensitive) and the job takes that place on the ladder, so it gets a next rank too; a title no catalogue knows is simply a job without a ladder.

Hiring. Jobs:hire(id [, rank]) makes the catalogue job the player's main job: the rank's title and salary, and the building the player is in becomes the employer, as Game:moveJobs does, so do it at the venue. It refuses, with the first missing thing, unless every rank up to the one asked is met. jobChanged reports "hired".

lua-- in a scene at the bar
for _, job in ipairs(Jobs:catalogue(Places:here().tag)) do           -- the vacancies of this venue
	-- job = { id, name, field, mod, workplaces, learns, canApply, why, held,
	--         ranks = { { title, salary, months, met, why, current, knowledge } } }
	-- ranks[].knowledge is the REQUIRES.KNOWLEDGE strings ("ynl_bar_work:50")
end
local ok, why = Jobs:canApply("ynl_bartender")                       -- rank 1 unless told otherwise
if not ok then
	Game:Say(Actor, "Neutral", "Not yet. " .. why)                     -- "Needs Bar work 50 (Competent), you are at 31."
	return
end
Game:Say(Actor, "Happy", "Start Thursday. Barback, " .. Jobs:catalogue()[1].ranks[1].salary .. " a month.")
Jobs:hire("ynl_bartender")                                           -- or Jobs:hire("ynl_bartender", 2) for an experienced hire

Promotion. Jobs:nextRank() says what the next rank is and what still stands in the way; Jobs:promote() applies it (title, salary, rank, the tenure clock reset) and reports "promoted" on jobChanged. Both take a job id and default to "main".

lua-- "Ask for a raise" at work
local next = Jobs:nextRank()                 -- nil at the top, or for a job with no ladder
if not next then
	Game:Say(Actor, "Neutral", "There's nowhere up from here.")
elseif next.why then                         -- "Needs 2 more months in the current role." / "Needs Mixology 50 (Classics), you are at 38."
	Game:Say(Actor, "Neutral", next.why)      -- next = { title, salary, months, monthsLeft, why }
else
	Game:Say(Actor, "Happy", next.title .. ", then. " .. next.salary .. " a month.")
	Jobs:promote()
end

local ladder = Jobs:ladder()                 -- the held job's catalogue entry with rank, months and
                                             -- ranks[].current; nil when it is not from the catalogue

NPCs on the ladder. Everyone else shares the catalogue. Once a day, an NPC whose job title is a rank's TITLE or one of the job's ALIASES is placed on that ladder: the knowledge their rank asks is seeded a little above the bar (prerequisites too), its qualifications awarded, and some tenure assumed. From then on they practise the job's LEARNS most days, and when tenure and the next rank's requirements are met they are promoted, title and salary, on a slow daily roll, so a ward's nurses do not all become senior on the same morning. Jobs:careerOf(person) reads where someone stands; for the player it reads the main job's ladder.

lualocal c = Jobs:careerOf(Actor)     -- nil when their job is not on the catalogue
if c and c.rank < c.ranks then     -- { id, name, field, title, rank, ranks (how many), months (in this rank) }
	Game:Say(Actor, "Sad", "Still " .. c.title .. " after " .. math.floor(c.months) .. " months.")
end

Two notifications, once each, checked daily: Ready for promotion when the held job meets everything its next rank asks ("Ask for it at work": your promotion scene does the rest), and You now qualify when the player first meets the entry rank of a catalogue job they do not hold, naming its workplaces. Jobs whose first rank asks for nothing are never announced.

Call Does
Jobs:catalogue([workplaceTag]) The catalogue, or the jobs offered at venues of that tag.
Jobs:canApply(id [, rank]) ok, why for being hired at that rank now.
Jobs:hire(id [, rank]) Hires into the main job here; ok, why.
Jobs:ladder([jobId]) The held job's catalogue entry and rank, or nil.
Jobs:nextRank([jobId]) { title, salary, months, monthsLeft, why }, or nil.
Jobs:promote([jobId]) Applies the next rank; ok, why.
Jobs:careerOf(person) Anyone's place on a ladder: { id, name, field, title, rank, ranks, months }, or nil.