Date and Time - ThePix/QuestJS GitHub Wiki

Quest tracks time by the second, and by default 60 seconds elapses each turn (change settings.dateTime.secondsPerTurn to modify). The total elapsed time is stored in game.elapsedTime.

Note that Quest 6 can handle dates about a quarter of a million years into the future or past, beyond that it will become less and less accurate. That said, I do not think it can handle anomalies, for example the missing days of 1752, and I suspect it will assume the Gregorian calendar for earlier dates, even though the Julian calendar was used prior to 1582, so may get the day of the week wrong for earlier dates.

Setting up

You can set the start time like this:

settings.dateTime.start = new Date('April 14, 2387 09:43:00')

Then use util.getDateTime() to get the current game time as a string, or the "dateTime" text processor directive. If you want the actual data, it is more convenient to use util.getDateTimeDict(), which will return the time as a dictionary, like this:

{
  date: 14
  hour: 13
  minute: 31
  month: "February"
  second: 0
  weekday: "Thursday"
  year: 2019
}

You can then do as you like with the data.

Basic Date Formatting

Quest 6 uses the standard JavaScript date formatting, which can be customised by modifying the settings.dateTime object. This is an example (note that it sets the start time too, you do not need to do both this and the above).

  settings.dateTime = {
    year:"numeric",
    month:"short",
    day:"2-digit",
    hour:"2-digit",
    minute:"2-digit",
    secondsPerTurn:60,
    locale:'en-GB',
    start:new Date('February 14, 2019 09:43:00'),
  }

You may want to investigate what the options mean here.

Actions that take longer

To have an action take longer than the standard minute, simply add the extra seconds to game.elapsedTime in your command (or subtract for shorter times).

For an exit, you can set an "extraTime" attribute, and this will be added to the elapsed time. This is in addition to the usual 60 seconds, and is in seconds, so if it takes ten minutes to walk to the house, it should be set to 9*60 (Quest is perfectly happy to have mathematic expressions as attribute values, and doing so for time can make it more obvious where the number comes from).

Useful functions

As Quest works in seconds, it is often useful to convert a number of days, hours, minutes and seconds to just seconds. The util.seconds function does just that. The util.elapsed function returns true if that number of second has elapsed in the game.

util.seconds(30, 4)    // get the number of seconds in four and a half minutes
util.seconds(0, 0, 3)    // get the number of seconds in 3 hours
util.seconds(0, 0, 0, 1)    // get the number of seconds in 1 day

util.elapsed(30, 4)    // true if four and a half minutes (or more) has elapsed
util.elapsed(0, 0, 3)    // true if three hours (or more) has elapsed
util.elapsed(0, 0, 0, 1)    // true if one day (or more) has elapsed

If you prefer to use actual times, use the util.isAfter function. This can be sent a full date and time, in which case it will return true if the game time is after that moment, or just a time in 24 hour clock, and then it will return true it it is later than that time, regardless of the date. You can also pass it the number of seconds since the game started.

if (util.isAfter('February 14, 2019 09:43:00')) msg("Since the explosion, the shed is just a pile of rubble.")
if (util.isAfter('0903') && !util.isAfter('1718')) msg("The shop is currently open.")
if (util.isAfter(3*60)) msg("The gnome has disappeared.")

This makes it easy to set up a shop, for example, that is only open certain times of the day. As you may have several such businesses, we will create one function to cover them all (and assign it to the util object with the existing time functions).

util.openingTimes = function () {
  if (util.isAfter('1700')) return falsemsg('The business is now closed.')
  if (!util.isAfter('0800')) return falsemsg('The business closed until eight.')
  return true
}

All it does is tests if it is after five in the afternoon; if it is, it returns false and gives a message. Then it checks if it is before eight in the morning, if it is, it returns false and gives a message. If neither failed, it returns true.

We then need to add that to the exits going into the shop (do not add to the exits out of the stop; you do not want the player trapped there overnight). To have an exit use our script, just set its "isUnlocked" attribute, as seen here:

createRoom("wheat_road", {
  desc:"The east side of Halmuth is the poorer side of the city. ...",
  west:new Exit('market_square'),
  northwest:new Exit('madame_rels', {isUnlocked:util.openingTimes}),
})

The hour text processor directive

If you are tracking time, it can be useful to have descriptions change depending on the time of day. To facilitate this, there is the hour text processor directive. This requires 3 parameters; the start hour, the end hour, and the string to print if it is between the two.

The market square is large. {hour:0:8:It is quiet this early in the morning}{hour:9:17:It is busy}{hour:17:24:Despite the market being closed, they are still many people around}.

Custom Calendars

If you are setting your game in a fantasy world and you want to track time, you may want to create your own calendar, with its own months and days of the week. This is not trivial; at least some familiarity with JavaScript will be useful. The system is designed to be flexible and comprehensive, with ease of use less of a priority.

With a custom calendar set up, the util.getDateTime() and util.getDateTimeDict() functions will automatically use that rather than the normal calendar. Plus, you get some bonus options, selectable by sending a dictionary as a parameter. Use "format" to select a format (we will set these up later), use "add" to get a time so many seconds into the future (or past if negative), use "is" for a specific date and time (relative to the game start time).

util.getDateTime({format:'time'})
util.getDateTime({add:10000000})

These can also be used with the text processor directive, but you have to get the order right: format, is, add. The "format" is set in the first example, "add" in the second (note the extra colons).

It is {dateTime:time}.
It will be {dateTime:::1000} in 1000 seconds.'))

This cannot cope with negative values by the way - but you can get around that by starting the calendar sufficiently in the past (say 4000 BC), but have the year report relative to your zero (so if the year is less than 4000, report as BC, otherwise as AD). Be careful what happens around the year zero - in our calendar here is no year zero.

Basic data

Again, it uses the settings.dateTime object. We start by giving it some data. I am going to create a standard calendar (but ignoring leap years), as this will hopefully make it clearer what I am doing.

I am assuming we are crrating a new settings.dateTime object, so the default values will be lost, specifically "secondsPerTurn", so we need to include that. Also, the start time should be set here.

  startTime:1234567890,
  secondsPerTurn:60,
  data:[
    { name:'second', number:60 },
    { name:'minute', number:60 },
    { name:'hour', number:24 },
    { name:'day', number:365 },
    { name:'year', number:999999 },
  ],
  months:[
    { name:'January', n:31},
    { name:'February', n:28},
    { name:'March', n:31},
    { name:'April', n:30},
    { name:'May', n:31},
    { name:'June', n:30},
    { name:'July', n:31},
    { name:'August', n:31},
    { name:'September', n:30},
    { name:'October', n:31},
    { name:'November', n:30},
    { name:'December', n:31},
  ],
  days:['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],

The "data" attribute is an array of time divisions, each has a name and the number of them in the higher division. Thus, for the first the name is "second", the smallest time division we are concerned with, and the number is 60 because there are 60 seconds in a minute. Weeks and months are complicated, so I just do 365 days in a year for now (if I wanted to handle leap years I would stop at days, and sort that out elsewhere). Note that the last entry has a very big number; we never want to have dates that might exceed that.

Then there are two more lists, one defining the months and the other the days of the week. The former also needs the number of days in each month. These are for use later.

Formats

You can specify any number of formats. There might be a time you just want the day of the week, and another occasion you just need the time. Each format is an entry in a dictionary. Note that one must be called "def", which will be the default ("default" is a keyword, which is why "def" is used).

In this example only two are set.

  formats:{
    def:'%dayOfWeek% %dayOfYear%, %year%, %hour%:%minute% %ampm%',
    time:'%hour%:%minute% %ampm%',
  },

Each format is a string composed of ordinary characters and function names surrounded by percentage signs. Looking at the time format, this has the "hour" function, followed by a colon, then the "minute" function, a space and then the "ampm" function (which will add either "am" or "pm").

Now we just need to define those functions...

Functions

Every function you cite in a format needs to be defined in the functions dictionary. That means six for the formats above (and that will be pretty typical).

Each should take a dictionary as a parameter and return a string. The dictionary parameter will have a number of entries depending on the "data" attribute we set earlier. In this case these will be "second", "minute", "hour", "day" and year" because these are the names in the settings.dateTime.data array.

  functions:{
    year:function(dict) { return dict.year },

    hour:function(dict) { return dict.hour < 13 ? dict.hour : (dict.hour - 12) },

    minute:function(dict) { return dict.minute < 10 ? '0' + dict.minute : dict.minute },

    dayOfWeek:function(dict) { 
      return settings.dateTime.days[(dict.day + 365 * dict.year) % settings.dateTime.days.length] 
    },

    dayOfYear:function(dict) {
      let day = dict.day
      for (let el of settings.dateTime.months) {
        if (el.n > day) return (day + 1) + ' ' + el.name
        day -= el.n
      }
      return 'failed'
    },

    ampm:function(dict) {
      if (dict.minute === 0 && dict.hour === 0) return 'midnight'
      if (dict.minute === 0 && dict.hour === 12) return 'noon'
      return dict.hour < 12 ? 'am' : 'pm'
    },
  },

The first function is the year. This is very easy, it just returns the value of "year" from the dictionary. The hour one is a little more complicated as it has to subtract 12 if this is after midday. The minute one has to add a zero before the number if it is only single digits.

Day of the week has to get the number of days since the calendar began, (dict.day + 365 * dict.year and use modulo arithmetic to determine the day of the week, getting the name from the array we set up earlier.

The day of the year goes though each month successively reducing day until we find the month it falls in, then returns the resultant number with the month.

Finally "ampm" returns "midnight" or "noon" if it is either specific time or "am" or "pm" depending on the value of "hour".

Altogether now

Here is what it looks like when completed:

settings.dateTime = {
  startTime:1234567890,
  data:[
    { name:'second', number:60 },
    { name:'minute', number:60 },
    { name:'hour', number:24 },
    { name:'day', number:365 },
    { name:'year', number:999999 },
  ],
  months:[
    { name:'January', n:31},
    { name:'February', n:28},
    { name:'March', n:31},
    { name:'April', n:30},
    { name:'May', n:31},
    { name:'June', n:30},
    { name:'July', n:31},
    { name:'August', n:31},
    { name:'September', n:30},
    { name:'October', n:31},
    { name:'November', n:30},
    { name:'December', n:31},
  ],
  days:['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],

  formats:{
    def:'%dayOfWeek% %dayOfYear%, %year%, %hour%:%minute% %ampm%',
    time:'%hour%:%minute% %ampm%',
  },

  functions:{
    year:function(dict) { return dict.year },
    hour:function(dict) { return dict.hour < 13 ? dict.hour : (dict.hour - 12) },
    minute:function(dict) { return dict.minute < 10 ? '0' + dict.minute : dict.minute },
    dayOfWeek:function(dict) { 
      return settings.dateTime.days[(dict.day + 365 * dict.year) % settings.dateTime.days.length] 
    },
    dayOfYear:function(dict) {
      let day = dict.day
      for (let el of settings.dateTime.months) {
        if (el.n > day) return (day + 1) + ' ' + el.name
        day -= el.n
      }
      return 'failed'
    },
    ampm:function(dict) {
      if (dict.minute === 0 && dict.hour === 0) return 'midnight'
      if (dict.minute === 0 && dict.hour === 12) return 'noon'
      return dict.hour < 12 ? 'am' : 'pm'
    },
  },
}

You can add your own util.seconds function by setting settings.dateTime.convertSeconds. The util.elapsed function will then use that to work correctly with the new time scheme. That said, I am doubtful messing with the time is a good idea, as it will be too disorientating for the player.

Functions and text directives

The dateTime directive will work fine. The hour text directive will work with custom date-times as long as there is an "hour" entry in the data list.

The functions util.getDateTimeDict and util.getDateTime will both work as expected. The util.isAfter function will not work for full date-time strings, but will work with twentyfour hour clock (as long as you still have "hour" and "minute" in your data) and with numbers..

If your have set up seconds, minutes and hours as normal, then util.seconds and util.elapsed functions will also work okay. If you have a custom time system you will need to define a settings.dateTime.convertSeconds function. This example has 100 seconds in a minute, 100 minutes in an hour and 10 hours in a day.

settings.dateTime.convertSeconds = function(seconds, minutes = 0, hours = 0, days = 0) {
  return ((((days * 10) + hours) * 100) + minutes) * 100 + seconds
}