Table of Contents
Cycle Handler
Note: This is a living document and will be updated each time there is a change to the output of the external application, the structure of the JSON files or if there is any fundamental change. Last Update: 2025-Dec-19 11:53
The NPC controller will despawn actors (NPCs) when there are no nearby agents (visiting avatars). Some won't despawn because they are selected to always be alive - at least during their shift. When an agent arrives, it will be necessary to respawn those that are on shift, and placed where they should be at that particular time of day. The actions they take should also be based on what they should be doing at that location. This requires a system to quickly identify what frame (in-world day) of the real-world day is currently active, and which frame of the year that represents, so that it is then possible to calculate which shift an NPC should currently be on (a shift equals one in-world day, or frame), and which part of that shift (based on the time), so it is then possible to determine where the actor should be (a vector), their orientation, what clothes they are wearing, what their profile should read and which images to use for the profile (these can change as the actor's circumstances change) and what actions they should be taking there.
This is a lot of calculating and must be performed for each actor rapidly so they can all be spawned in the right place and set to performing the correct actions, so the visiting agent can appreciate them in all their splendour, and - more importantly - bring the region to life for them.
The most efficient way to do this without placing a great deal of burden on the region simulator, is to make this an external application which the in-world controller can call. This also means the data files for each actor can exceed the size limit imposed on most scripts.
Basic Time
The system runs on the America/Los Angeles timezone, as is typical of Second Life and Opensimulator worlds. This means I need to convert my local time (GMT/BST) to PST/PDT, then calculate the 'apparent' in-world time. I also have to know how many days since the beginning of the year as this allows me to determine what day of the shift cycle an actor should be on.
Time conversion is relatively easy. All you need to know is the current time in Los Angeles, and most programming languages these days will allow you to determine that. Since an in-world day is 4hrs, there are 6 'days' in 24hrs. For clarity, I'll refer to the 'days' in a 24hr period as frames, with six frames per day.
Another way to think of this is that time passes six times faster in-world, so one second in the real is six virtual seconds. The night portion of the cycle is 25% of the full day, centered on midnight - so 21:00 - 03:00 (9pm to 3am). This means there are also six times as many days in a year - 2,190 days (plus an extra 6 in leap years).
Calculations
To calculate which day of the year in PHP is as simple as:
date_default_timezone_set("America/Los_Angeles"); define( "FRAME", 14400 ); // 4hrs in seconds $rldays = date('z'); // this gives a zero-based day count $world_days = $rldays * 6; // number of days passed in-world less today's frames $time = params->time; // seconds since midnight PST/PDT from calling script $frame = (int) ($time / FRAME); // get how many in-world days have passed this RL day $world_days += $frame; // add completed frames to world day count $utime = $time % FRAME; // get the RL seconds in the current frame $utime *= 6; // this is the in-world (apparent) time, in seconds $tstring = gmdate( "H:i:s", $utime ); // $utime is apparent time in seconds // $tstring is $utime converted to a string such as "12:45:30" // the input time is in seconds, so the output must be in multiple of 6 // e.g. the seconds reported in a minute are: 0, 6, 12, 18, 24, 30, 36, 42, 48, 54 // intermediate values are skipped unless fractional seconds are used in the input with // a granularity of 0.016666667 (the equivalent of one in-world second)
Before embarking on writing something in Rust or Golang, it's often useful to write the code in PHP for the rapid development time.
As you may have guessed, I'm using an external program to handle all the heavy loading on this, and it uses JSON files to store an actors cycle data. A cycle is a collection of one or more shifts. A shift can include a day off for the lucky actor. Right now, it's a simple service running on a web server using https. In the future I would like to use a UDP server, but that would require creating a UDPBridgeModule, similar to the IRCBridgeModule, giving in-world scripts access to external applications via the UDP protocol.
I created a class daycycles which handles all the above calculations and does so more efficiently, and is capable of returning additional time-based data. Example:
date_default_timezone_set("America/Los_Angeles"); include_once("lib/daycycles.php"); $params = new parameters(); $dc = new daycycles(); $region = $params->region; $npc = $params->npc; $npc_prep = strtolower( substr( $npc, 0, 1 ) ); $path = "regions/$region/$npc_prep/$npc.json"; $dc->loadJson( $path ); $shift = $dc->fetchCurrentShift(); $nevent = $dc->json->cycle[$dc->nextshiftid][$dc->nextactionid]->start; echo "wtime: $dc->tstring\nStart: $shift->start\nType: $shift->type\n"; if( $shift->type != "sleep"){ // list the actions echo "Pos: $shift->pos\nActions: ".implode(", ", $shift->actions)."\n"; } echo "Next Event: $nevent\n";
This can be expanded upon greatly, but the above should give you an idea of how simple it is. Here's some sample output from the actual program - which outputs JSON - using the JSON file noted in the next section as input. I have added annotations to better explain the fields. Note there is a wealth of information about the current frame and the current time-chunk within it, plus information about the next chunk (if one exists) and the next frame.
{
"err": false, # true if app terminated early (other fields become untrustable)
"npc": "Whistle Blower", # which NPC the data is for
"fname": "Obsolete", # first name
"lname": "Character", # last name
"gender": "f", # gender: f,m,n
"gmt": "13:41:14", # current time in Britain (for reference)
"bst": false, # Whether British Summer Time is active
"pst": "05:41:14", # Time in Los Angeles (Second Life/OpenSimulator default timezone)
"pstwc": 20474, # 'pst' in RL seconds since midnight (osPSTWallClock())
"pdt": false, # Whether Pacific Daylight Savings Time is active
"tstring": "10:07:24", # Current time within frame
"frame": 2, # Current frame (0-5)
"next_frame": 3, # next frame - could be 0
"next_frame_start": 43200, # time next frame starts
"curshift": 1, # Which shift pattern we are on
"shift_start": "08:05:10", # time the shift started
"shift_index": 29110, # index of the section of the shift
"shift_type": "none", # type of shift - sleep despawns the npc
"shift_sleep": false, # true if shift_type is "sleep" or "despawn"
"pos": "<140,140,0.5>", # where npc should be spawned - unused for sleep and despawn or if already spawned
"rot": "<0,0,0,0>", # rotation of NPC during spawn (zero rot provided if this is missing)
"actionid": 2, # which action within the current shift we are on
"nextactionid": 3, # id of the next action
"nextshiftid": 1, # the ID of the next shift pattern (0=begin cycle again)
"nextshiftstart": "18:00" # time the next shift starts as a string in frame time
"nextshiftunix": 108000, # real-world time in seconds as offset from midnight
"actions": [
"waituntil|18:30"
],
"keepalive": false, # keep alive when no agents in region
"autospawn": false, # if keepalive is true, respawn after region start
"pabout": ".profile_01", # name of profile notecard
"pimage": ".profile_img_01", # name of profile image
"auuid": "049c0a7f-dcda-4eef-84bb-a2204065ae14", # uuid of profile notecard
"iuuid": "0e6d9d7f-6ccd-4054-a18b-b0fdf3294719", # uuid of profile image
"outfit": "c89c993e-05a7-4e88-98ac-18d4c14755f6" uuid of appearance notecard
"walk": "walk1", #
"run": "run1", #
"stand": "stand1", # names of default animations to use
"dance": "dance1", # (must exist in controller inventory)
"sit": "sit4", #
"zone": ["zone1"..."zoneN"] # areas of visibility (won't be rezzed if no agents in these zones)
}
Note the next shift can point to the same shift if there are more actions, to the next shift if there is one, or zero if there are no further shifts (rolling back to the first entry in the cycle). The shift_index is the shift_start expressed as seconds. This allows you to add, delete or insert shifts without having to renumber the indexes. The indexes are calculated when these JSON objects are installed (see Installing Objects below).
The nextshiftid may be confusing. It refers to the shift where the next action block will come from and will usually be the same as the current shift, unless the currently returned action block is the last in the shift.
As you can see, there is a wealth of information, some of which will enable the receiving in-world script to perform short-cuts in the code:
if( json2Integer( json, "shift_sleep" ){ if( key id = npcExists( json2String( json, "npc" ) ){ osNpcRemove( id ); } } else { // perform actions }
Note too the nextshiftunix time is an offset in seconds from midnight. This is calculated as follows:
$time = "02:00"; $time = time2secs( $time ); // 1200 $time += ($nextshiftid * 14400); // 14400 = 4hrs or 1 frame
Nearly all the fields returned are describing time in one form or another.
Error Value
The return result has an “err”: false return value. This should always be tested. If it is true, the likelihood is the rest of the object is either unreliable (some error occurred) or missing (the requested actor data was not found).
JSON Format
Here's an example of a simple JSON file for an actor with a two shifts in their cycle so the actor will alternate between them each in-world day (frame). Note the actions defined below can be interrupted by the Controller, which may instruct the actor to greet an agent for example. Then the actor will resume.
{
"warning": "DO NOT EDIT - UPDATED BY AUTOMATION",
"name": "Whistle Blower",
"shifts": 2,
"keepalive": false,
"autospawn": false,
"gender": "f",
"outfit": {
"name": "Celine Complete Avatar",
"uuid": "c89c993e-05a7-4e88-98ac-18d4c14755f6"
},
"profile": {
"about": ".profile_01",
"image": ".profile_img_01",
"auuid": "049c0a7f-dcda-4eef-84bb-a2204065ae14",
"iuuid": "0e6d9d7f-6ccd-4054-a18b-b0fdf3294719"
},
"animation": {
"stand": "stand1",
"walk": "walk1",
"sit": "sit4",
"run": "run1",
"dance": "dance1"
},
"acq": [
"Fred Bloggs"
],
"frs": [
"Random Value",
"Lucky Girl",
"Tom Jones"
],
"cycle": [
{
"shiftid": 0,
"neg": [],
"entries": [
{
"index": 0,
"start": "00:00",
"pos": "<128,128,0.5>",
"rot": "E",
"name": "sleep",
"type": "sleep"
}
]
},
{
"shiftid": 1,
"neg": [],
"profile": {
"about": ".profile_02",
"image": ".profile_img_02",
"auuid": "c45da8e6-6183-46d1-accc-6556b14b6a8a",
"iuuid": "7c033af6-1a6f-473e-a42e-baa08c2b0159"
},
"entries": [
{
"index": 7200,
"start": "02:00",
"pos": "<128,128,0.5>",
"rot": "E",
"name": "sleep",
"type": "spawn",
"actions": [
"stand",
"say|Hello World!",
"waituntil|08:05"
]
},
{
"index": 8400,
"start": "02:20",
"pos": "<128,128,0.5>",
"rot": "<0,0,0,0>",
"name": "sleep",
"type": "none",
"actions": [
"goto|<140,140,0.5>",
"say|Hello World from Somewhere Else!",
"waituntil|08:05:10"
]
},
{
"index": 29110,
"start": "08:05:10",
"pos": "<140,140,0.5>",
"rot": "E",
"name": "sleep",
"type": "none",
"actions": [
"waituntil|18:30"
]
},
{
"index": 64800,
"start": "18:00",
"pos": "<140,140,0.5>",
"rot": "E",
"name": "sleep",
"type": "despawn"
}
]
}
]
}
Having loaded the JSON file for the actor “Whistle Blower”, the program calculates which part of the shift the actor should be on based on the actual time using the calculation mentioned earlier. For convenience, times in the JSON file are noted in hh:mm:ss format, with the seconds being optional and assumed to be zero when omitted. The following is an explanation of the fields:
-
startis the beginning of the time frame for a particular set of actions -
posis a vector. This is only used when respawning an actor to put them in the correct place -
rotis either a cardinal direction (E,NE,N,NW,W,SW,S,SE) or a rotation in <0.0, 0.0, 0.0, 0.0> format -
typeis one of:-
sleep- day off, semantically the same as despawn -
any - anything else is an action and the type may be used by the user to add a description
-
additional types may be added in the future
actionsis an array of instruction to be sent to the actor. They are played out in order, and one must finish before the next is played. The last action is always assumed to be a waituntil if it is omitted, with the time portion of the waituntil set to the next action (or if this is the last action in a frame, the first action of the next frame, or first frame if this is the last frame).Note that the
sleepevents don't require any actions. Only thesleepevent is significant, all other events are treated the same. A sleep event will despawn the avatar if it exists, and then insert afetchaction for the next action block.An action could be an instruction to load a local script - that is one stored in a notecard in the prim containing the actor's configuration (each actor gets their own prim in the controller wall).
At 08:05 the actor will walk to a new position, then say something when they get there, then wait until 08:05:10. It is likely that this time has already passed in-world, so the next section kicks in and instructs the actor to wait until 18:00, then the final section will play out which will despawn the actor.
The program will return the fields
pos, rot, type, actionsand add a new field being the unixtime of the next event, which requires the program to convert from an in-world day to the current time in PST/PDT format expressed as seconds. This allows the controller script in-world to check when the next fetch should occur (if there is one) by comparing that toosGetPSTWallclock()which returns the current PST or PDT time in seconds since midnight.If the first action of the day is in the future, this
next_event_timefield will tell the controller when to perform the next fetch for this actor. This could be the start of the next shift, or if there isn't a next shift, the first event in the cycle. In the above example, if the time is after 00:00 on the second day, the next event will be at 02:00 on the first day (because the cycle has umm… cycled).At the object's top level you'll also note additional fields:
-
keepalive once spawned, keeps the npc spawned when there are no agents in the region
-
autospawn respawns an npc after a region start. keep-alive must also be true or the npc will immediately despawn (because there will be no agents in the region)
-
acq is a list of acquaintances (will say hello when they are near them)
-
frs is a list of friends (will engage in conversation when near)
-
profile which contains the default profile image and 'about' notecard names and uuids
-
animation lists the various animations to be used for this avatar
-
gender which can be
f(female),m(male) orn(neutral)
The autospawn and keepalive are kept as two separate properties because there is hidden complexity. If the shift data says to despawn an NPC, keepalive will be ignored. After a region restart, a missing shift pattern to cover the current time/day will cause autospawn to be ignored.
This means that if there is an active shift pattern that would rez an NPC when an agent enters the area, then autospawn will rez the NPC after a region restart, but only if the NPC is marked as a keep-alive.
The keepalive NPCs are those that you absolutely must have already spawned when an agent enters the area. If you have a large number of NPCs, this guarantees the availability of the NPC for such things as greeting the agent. The autospawn property enhances the availability of that NPC to the first agent to enter the area after a restart.
The profile object can be overridden in a shift block. The values of the profile object are the names and UUIDs of files in the user's inventory (
not the prim inventory!). In the above example,
shift 0will default to the values set in the uppermost block (.profile_01and.profile_img_01), whileshift 1will use the values.profile_02and.profile_img_02. The uuids are used to fetch data, the names of the files are provided as a sop to ease editing.If a profile block is not provided in a shift block, a backwards scan will be performed to fetch the most recently set profile data.
The animation block lists the various animations that can be used. Note the fly, swim, float, hover animations, and some others, are not included simply because I did not need them for my simulation. These can easily be added, or animations added to chairs, pose balls etc. The animations are listed by name since they must be in the task inventory.
The gender can be used for any purpose. I'll be using it in the algorithm which determines who triggers conversations (there are other criteria besides gender for when they are equal).
The rot will always be returned as a rotation. The eight cardinal directions E,NE,N,NW,W,SW,S,SE (always in uppercase) can be used as a shorthand for this, and will be converted to a rotation before being returned. If a non-cardinal rotation is required, it must be supplied in full as a rotation with only the
r.zandr.svalues set (otherwise the results will be undetermined - an OpenSimulator limitation). The rotation must include the angle brackets.Outfits
Outfits are slightly oddball. An outfit defined in the outermost block is the default outfit.
An outfit defined in a shift block is the default outfit for that shift and all subsequent shifts until either another outfit is defined in a shift block, or the end of the cycle is reached.
An outfit defined in an entry block becomes the default for the rest of the shift.
Therefore, in deciding which is the current outfit:
-
search backwards through the current shift's entry blocks looking for an outfit
-
search backwards through the shift blocks looking for an outfit
If an outfit is found, use that, otherwise use the outfit defined in the outermost block. Note that only the current shift's entries are searched, as these are pertinent only to that shift.
Outfit blocks have two entries:
-
name - the actual name of the notecard in the user's inventory
-
uuid - the UUID of the notecard
Only the
uuidis used, in the output object, the name is simple to make locating the item in inventory easier.This allows the actor to wear different clothes on different days or different times of the day. The clothing change doesn't have to be repeated as a look-back takes place. However, it would be optimal to repeat a clothing change once a week to optimise the reverse search.
Friends and Acquaintances
In each cycle object there is a
negarray. This is a list of actors to temporarily remove from the lists of friends and acquaintances. This is useful after a partnership breakup.The lists of friends and acquaintances are fetched in a separate call to the server. Such lists, when added to the rest of the data, have the potential to exceed the maximum data length that can be received, hence they are acquired separately. They are not needed immediately the actor is spawned anyway. Here is the outermost part of an object for another actor:
{ "name": "Random Value", "shifts": 2, "keepalive": false, "autospawn": false, "acq": ["Fred Bloggs"], "frs": ["Whistle Blower", "Lucky Girl", "Tom Jones"], "cycle": [...] }Lets assume the
cyclearray is identical to the aforementionedWhistle Blower's array, but with the following changes:"cycle": [ { "shiftid": 0, "neg": ["Fred Bloggs"], "entries": [...] }, { "shiftid": 1, "neg": ["Lucky Girl"], "entries": [...] } ]Running a
friendsrequest duringshift 1will result in the following data:{ "err": false, "npc": "Random Value", "frame": 2, "curshift": 1, "acq": [ "Fred Bloggs" ], "frs": [ "Whistle Blower", "Tom Jones" ] }Note that
Lucky Girlhas been dropped from the friends list. Duringshift 0,Fred Bloggswill be dropped from the acquaintances list - names in thenegarray are dropped from both the acquaintances and friends lists.After a breakup or some other falling out with another actor, simply adding the offending actor's name to the
neglist will stop the current actor (in this caseRandom Value) from automatically saying hello to or engaging these actors in conversation.Also note that only one of the friends (or acquaintances) needs to (or should) know about the other, as this would be the friend/acquaintance that initiates conversations. For acquaintances this would be a simple 'hello'. For friends it will be a simple 'hello' when they are passing, or when standing near each other will become a full conversation where the primary friend (the one who 'knows' about the other) will puppet the other.
Installing Objects
I create a folder for each region where I store 'original' actor JSON objects, then run a script which 'installs' them to the folder where the PHP scripts can access them. During the installation, certain fields in the objects will be updated:
-
shiftscounter -
shiftidfor each shift in the cycle to ensure numbers are consecutive (in case any shifts have been inserted/deleted) -
indexto become thestarttime value converted to seconds
This means that in the 'original' folder, where I edit the objects, these values can be set to zero and I don't have to worry about them. It's important to edit these files only and not the working copies the scripts use.
Storage
I have options on the storage system for the data.
Filesystem
Simply put, there will be a folder for each region, containing the JSON files for each of the actors in that region, named after the actor. This makes the data for a particular actor easy to find for both the application that needs to read the information and for the poor sod who has to edit them every now and again.
MongoDB
This is a 'document database' where each record is a separate document. The beauty of this is that records don't have to have identical fields, and where they do, they don't have to be of the same type. Mongo stores the records in BSON (binary JSON), and the database can be queried using SQL-style syntax. It would simply require an additional field in the record for the region the actor lives in, and the region and name fields can be used to index.
MongoDB Compass can be used to add/edit/delete files in an ad-hoc manner.
MongoDB can be accessed using PHP, Golang and Rust since they all have libraries for it. It uses the WiredTiger storage system which aggressively caches to reduce disk access and retains high throughput for both read and write operations, making it high performance. It can scale both horizontally and vertically too, although I won't need that for my requirements.
I'll stick with the filesystem method for now.
Known Issues
Event Overflow
One of the most important issues that needs to be addressed very early on is the limitation on the number of queued events.
Each script has an event queue, and this has a predetermined length of 64. When spawning actors it is necessary to request information about their current shift pattern so they can be spawned in the correct location and fed the correct initial series of actions. Trying to spawn more than 64 actors at once could result in lost events, because incoming events are discarded if the event queue is full.
Actors that have been spawned may in turn send requests to the controller, and there will be a timer event that is constantly running and will likely trigger several times, so you have to allow for these events in calculating how many actors it is safe to spawn at the same time.
The solution is to spawn them in blocks of thirty, which will allow for all events to be handled, and once they have all been spawned and configured move onto the next block of thirty. This way it should be possible to manage as many actors as your simulator can handle.
Size of Action Data
The complexity of cycles and shift patterns means that for some actors a single action notecard covering every eventuality makes managing such notecards a messy affair. It also means the data may not be transmissable to the controller from the external application because of limits on data size imposed by the simulator.
By only sending the action sequences for the current part of the day the amount of memory consumed by any script is minimalised and there is no chance of a data loss due to truncation.
Conclusion
While this is incomplete, there is enough definition here to see how it should work and building on it shouldn't be hard.
This project is stored in my private ForgeJo server. When it is fully working and tested, I'll link it to a GitHub repo.
-

Discussion