Lua scripting

Game-time events

React to new days and hours, and schedule callbacks that survive saving, sleeping and travel.

A library often needs the clock: a reply that arrives three hours after a text, a plan that goes overdue, a weekly meeting. Checking "has enough time passed?" only when the player opens your page means nothing happens while they sleep. The Events global runs your code in game time, while the game is playing, whatever moved the clock.

Events exists from the moment your library loads, so you can register at the top of Lua/*.lua. Every method takes your mod's folder name first: it keeps your saved calls apart from other mods' and names you in the log when a callback fails.

Subscriptions

lua-- Lua/ynl_clock.lua
local M = "ynl_Nightlife"

Events:on(M, "newDay", function(day)
	-- once per midnight crossed; day counts from the start of the game
end)

Events:on(M, "hour", function(hour)
	-- once per whole game hour crossed
end)

Events:on(M, "loaded", function()
	-- once after a save has been loaded
end)

Events:on(M, "actionDone", function(actionId, targetId)
	-- the player finished an action; targetId is the person it was done with, or nil
end)

Events:on(M, "qualificationGained", function(id, personId)
	-- someone was awarded a qualification ("ul_Education/certificate_1")
end)

Events:on(M, "jobChanged", function(what, roleVersionOrJobId)
	-- the main job: "title", "workplace", "salary" or "lost", with its role version (see Work:snapshot());
	-- any job (Jobs): "added", "worked", "missed", "warning", "fired", "quit", with the job id ("main" for the main job)
end)

Events:on(M, "itemChanged", function(itemId, count, delta, owner)
	-- someone's objects changed (addItem, removeItem, giveItem, a shop purchase): the new count, the
	-- change (+2, -1), and whose: "player" or the person's id
end)

Events:on(M, "propTaken", function(itemId, buildingId, owner, propKey, takerId, witnesses)
	-- something was taken from a room that was not the taker's (see Shops and items, Things in
	-- rooms): owner is "venue", "home:<building id>" or "person:<id>"; takerId is "player" or a
	-- person id; witnesses = how many other people were in the room. Whether it was theft is yours.
end)

Events:on(M, "itemBought", function(shopId, itemId, count, buildingId)
	-- the player bought something in a shop (the shop screen, Buy on a shelf, Room:buy): the
	-- building's stock went down and as many of its props left the shelves; buildingId is "" in a
	-- room with no building (a test harness).
end)

Events:on(M, "storageChanged", function(containerKey, itemId, delta)
	-- something went into (delta > 0) or out of (delta < 0) a home's fridge, cupboard or wardrobe
	-- (see Shops and items, Home storage). containerKey is "<building id>|<room>|<Set>/<Node>@x,y".
end)

Events:on(M, "spoiledEaten", function(itemId, stockStat)
	-- the player ate food past its date (only when nothing fresh was left); storage.json's
	-- "spoiled" effects are already applied. Illness, a scene, a complaint: yours.
end)

Events:on(M, "weatherChanged", function(newId, oldId)
	-- the weather changed: the morning's draw, a scene's setWeather, or a forced weather ending
	-- (see Weather and seasons)
end)

Events:on(M, "awayChanged", function(what, place, mod)
	-- "left" or "back": a trip away from the city (Game:goAway) began or ended
end)

Events:on(M, "perkUnlocked", function(perkId, mod)
	-- the player bought or was granted a perk (see Perks)
end)

Events:on(M, "exposureChanged", function(personId, parts, sexiness)
	-- what someone shows changed: parts is "breasts,genitals,butt" (any combination) or "" once
	-- covered; sexiness the outfit's 1-5. Read it back with Actor:getExposure() (see People, Clothes).
end)

Events:on(M, "exposureSeen", function(viewerId, personId, parts, sexiness)
	-- someone present (within 15 m, not in a sex scene, not a minor) just caught sight of a body
	-- with something bare, or of an outfit scored 4 or more: once per viewer per spell, again for
	-- anyone who walks in and for everyone once the look has gone plain and come back. A stranger's
	-- id is not in People: pick who reacts with Game:getPersonHere in the scene you queue. What
	-- they do about it is yours; ul_Exposure answers by the viewer's personality traits.
end)

Events:on(M, "perksReset", function(points)
	-- every perk was taken back; points = how many were refunded
end)

Events:on(M, "newGame", function()
	-- once per life, when play first begins after character creation: pick a backstory, give a starting quest
end)

Events:on(M, "snapshotRestored", function(name)
	-- Game:restoreSnapshot(name) just put the game back (a time loop's new morning)
end)

Events:on(M, "playerSwitched", function(oldId, newId)
	-- Game:switchPlayerTo: play now continues as newId; oldId is a person they know
end)

Events:on(M, "questStarted", function(questId) end)
Events:on(M, "questCompleted", function(questId) end)

Events:on(M, "outcomeCommitted", function(mod, eventId, result)
	-- a narrative moment ended (see Outcomes and proposals)
end)

Events:on(M, "proposalChanged", function(mod, id, state)
	-- "proposed", "accepted", "declined", "withdrawn", "superseded", "cancelled"
end)

Events:on(M, "creationChanged", function(mod, id, what)
	-- "created", "given", "kept", "referred", "discarded" (see Creations and channels)
end)

Events:on(M, "postReacted", function(mod, channel, postId, personId)
	-- a contact reacted to a post, once each
end)

Events:on(M, "agendaChanged", function(mod, key, state)
	-- a plan in the shared agenda changed: "accepted", "completed", "missed", "cancelled"...
end)

Events:on(M, "sexDone", function(partnerIds, sceneId, tags)
	-- a Game:Sex the player took part in has ended, whatever started it (an action, a date,
	-- another mod's scene). partnerIds: the other participants' ids, comma-separated;
	-- tags: the scene's animation tags, comma-separated ("Aggressive" for a non-consensual one)
end)

Events:on(M, "personDied", function(personId, cause, killerId)
	-- a known person died: killed by a scene (Actor:die / Person:die), or of illness or old age.
	-- killerId: whoever die(cause, killer) named, "" if nobody. Every death in town, not only
	-- the player's people; People:get(personId) still answers (name, killer(), diedAt())
end)

Events:on(M, "conditionChanged", function(personId, conditionId, what)
	-- a lasting condition was "added" or "removed" (Actor:addCondition), for anyone
end)

Events:on(M, "questFailed", function(questId, reason)
	-- Game:questFail ended a quest as failed; reason is whatever the script passed, or nil
end)

Events:on(M, "graveVisited", function(personId)
	-- the player stood at that dead person's grave (ref:visitGrave, ul_Base's Visit a grave)
end)

Events:on(M, "npcSexSeen", function(a, b, kind)
	-- two NPCs started having sex in the room the player is in; kind: "willing" or "forced"
end)

Events:on(M, "npcSexEnded", function(a, b, how)
	-- how: "finished" or "interrupted" (People:interruptSex)
end)

Events:on(M, "contactChanged", function(personId, what)
	-- what: "contact", "uncontact", "blocked", "unblocked", "removed",
	-- or a role gained or lost: "role+Dating", "role-Spouse", ...
end)

actionDone, sexDone, personDied, conditionChanged, questFailed, graveVisited, npcSexSeen, npcSexEnded, contactChanged, agendaChanged, jobChanged, itemChanged, itemBought, propTaken, storageChanged, spoiledEaten, weatherChanged, awayChanged, perkUnlocked, perksReset, newGame, snapshotRestored, playerSwitched, questStarted, questCompleted, outcomeCommitted, proposalChanged, creationChanged, postReacted, walkedIn and doorLocked (people who come and go) report something that just happened. They run on the next frame where nothing holds the game, before the clock catches up, in the order they happened. Like subscriptions, they belong to the session: they are not saved, and nothing is reported before play starts (character creation builds a whole family through the same calls). Only changes that involve the player are reported, and personId is always the other person (personDied and conditionChanged are the exceptions: they are reported for anyone in town).

With ul_Blood, two more report a wound and who saw it, whoever was involved:

luaEvents:on(M, "wounded", function(victimId, byId, bone, severity, state)
	-- byId: "" when nobody did it; state: "standing" (holding it) or "down"
end)
Events:on(M, "sawWound", function(witnessId, victimId, byId, severity)
	-- once per person within reach when it happened (Reactions/wounds.json "witness")
end)
Events:on(M, "knockedOut", function(personId, byId, cause, minutes)
	-- Actor:knockOut: out cold for that many game minutes (byId: "" when nobody did it)
end)
Events:on(M, "cameTo", function(personId, cause)
	-- they came round: on the clock, or brought round with Actor:wakeUp
end)

A subscription lasts for the session. Your library registers it again every time it loads, and a mod that is turned off does not load, so it has none.

Saved calls

A call you schedule is saved with the game and runs once, at a game time, even if the player saves, quits and loads in between. Because a Lua function cannot be saved, the call names a handler you register at load time:

luaEvents:handler(M, "reply", function(payload, dueHours, nowHours, key)
	-- payload is the table you scheduled with (saved as JSON: strings, numbers, booleans, tables)
	-- dueHours is when it was due, nowHours the clock now: a call can run late (see below)
end)

-- In a scene or a page:
Events:schedule(M, "reply_" .. person:id(), 3, "reply", { from = person:id(), text = "On my way" })
Method Does
Events:schedule(mod, key, inHours, handler [, payload]) Runs handler once, inHours of game time from now. The same key again replaces the waiting call.
Events:scheduleAt(mod, key, atHours, handler [, payload]) The same at an absolute time (Events:now() counts in the same hours).
Events:cancel(mod, key) Drops the waiting call; true if there was one.
Events:due(mod, key) When the call will run, nil if none is waiting.
Events:now() Game hours since the start of the game.
Events:version() The API version, 1.

What to rely on

  • Order. When the clock jumps (sleep, travel, passTime, loading), the hours in between are replayed in order: calls that fell due in an hour, then that hour's hour event, then newDay at midnight. Two calls due at the same time run in the order they were scheduled.
  • Exactly once. A call is removed before it runs. Rescheduling the same key from inside the handler is a new call, not a repeat.
  • No replay of the past. A new game, or a save made before this API existed, starts from the current time.
  • Never during a scene. Nothing runs while a scene, the character creator or the city picker holds the game; it catches up afterwards. Do not show dialogue from a callback: Game:Say and choices cannot pause outside a scene and fail there.
  • Long jumps are spread out. At most 64 callbacks run per frame; a month asleep takes a few frames, in order.
  • Errors stay yours. A callback that raises an error is logged under your mod's name; the others still run.
  • Turned off is not forgotten. A call whose handler is not registered (your mod is turned off, or you removed the handler) waits. When the mod comes back, it runs late, with its dueHours, so check how late it is before acting on it.
  • Tell the player with Notify. A callback cannot open dialogue, but it can send a notification: a toast on the map and a row under the bell, which can link to your page or a person.
  • Catch-up sees the present. During a replay, nowHours is the current clock, not the hour being replayed. A call scheduled from inside a handler counts from now.