Issues with creating an in-game local Date/Time system

Started by LostTrainDude, Wed 13/06/2018 20:07:16

Previous topic - Next topic

LostTrainDude

Hi all!

I am tinkering with this idea of creating some sort of space trading sim in AGS and, after managing to scatter some "VIPs" in the universe, I wondered if I could have them run their own errands\missions.
These errands\missions, along with other features, are all going to have a start date and a due date. This led me to implement an internal clock and also a "calendar", in order to make date differences calculations.

I treated dates as managed structs as defined here:
Code: ags
// Time.ash

managed struct Date
{
   int Hour;
   int Minute;
   int Second;
   
   int Year;
   int Month;
   int Day;
   
   import bool IsLeap();
   import function Add(int days);
};

import int DateToRaw(int y, int m, int d); // converts years, months, days to days
import Date* RawToDate(int rawDate); // converts days to years, months, days

import function Clock(int speed); // speed = 40*x, where 40 is 1 second in game loops

import Date* StartDate; // set to 01-01-3200
import Date* CurrentDate; // updated by the clock


Code: ags
// Time.asc

Date* StartDate;
Date* CurrentDate;

int tick; // Game loops

// I know it is kind of a duplicate but I was testing different solutions
bool leapyear(int year)
{
   if (year %   4 != 0) return false;
   if (year % 100 != 0) return true;
   if (year % 400 != 0) return false;
   return true;
   
   //Alternative one-liner
   /* return year%4 == 0 && (year%100 != 0 || year%400 == 0); */
}

// I know it is kind of a duplicate but I was testing different solutions
bool Date::IsLeap()
{
   return leapyear(this.Year);
}

// This increases Date of x number of days, and keeps track of months and years
// It is not used in this example here but it works perfectly fine
function Date::Add(int days)
{ 
   for (int i = 0; i < days; i++)
   {
      this.Day++;
      
      if (this.Day > 28 && this.Month == 2)
      {
         if (!this.IsLeap())
         {
            this.Day = 1;
            this.Month++;
         }
         else
         {
            if (this.Day > 29)
            {
               this.Day = 1;
               this.Month++;
            }
         }
      }
      else if (this.Day > 30 && (this.Month == 11 || this.Month == 4 || this.Month == 6 || this.Month == 9))
      {
         this.Day = 1;
         this.Month++;
      }
      else if (this.Day > 31 && (this.Month == 1 || this.Month == 3 || this.Month == 5 || this.Month == 7 || this.Month == 8 || this.Month == 10 || this.Month == 12))
      {
         this.Day = 1;
         this.Month++;
         if (this.Month == 13)
         {
            this.Month = 1;
            this.Year++;
         }
      }
   }
}

// Convert YYYY-MM-DD to days
// Found on stackoverflow, here https://stackoverflow.com/a/14224238
int DateToRaw(int y, int m, int d)
{
   /* Rata Die day one is 0001-01-01 */
   if (m < 3)
   {
      y--;
      m += 12;
   }
   return 365*y + y/4 - y/100 + y/400 + (153*m - 457)/5 + d - 306;
}

// Convert days to YYYY-MM-DD
// includes code provided by vga256 and most of the code from the Date.Add() function
Date* RawToDate(int rawDate)
{
   // Initialize a new Date object
   Date* d = new Date;
   d.Year = 1;
   d.Month = 1;
   d.Day = 1;
   d.Second = 0;
   d.Minute = 0;
   d.Hour = 0;
   
   int rawdays = rawDate;
   int howmanyleapyears;
   
   // To make it start at 3200 subtract 1
   d.Year = rawdays / 365;
   d.Year--; // *
   
   // How many leap years from year 0001 to rawDate?
   //
   // PLEASE NOTE: I do this to keep the number of iterations low
   // in order to prevent the loop from hanging
   for (int i = 1; i < d.Year; i++)
   {
      if (leapyear(i)) howmanyleapyears++;
   }
   
   // If the current year is a leap year then calculate the
   // remainder out of 366 days
   if (d.IsLeap()) rawdays = (rawdays - howmanyleapyears) % 366;
   
   // Otherwise calculate the remainder out of 365 days
   else rawdays = (rawdays - howmanyleapyears) % 365;

   // Same as the one in the Clock() function
   for (int i = 0; i < rawdays; i++)
   {
      d.Day++;
      
      if (d.Day > 28 && d.Month == 2)
      {
         if (!d.IsLeap())
         {
            d.Day = 1;
            d.Month++;
         }
         else 
         {
            if (d.Day > 29)
            {
               d.Day = 1;
               d.Month++;
            }
         }
      }
      else if (d.Day > 30 && (d.Month == 11 || d.Month == 4 || d.Month == 6 || d.Month == 9))
      {
         d.Day = 1;
         d.Month++;
      }
      else if (d.Day > 31 && (d.Month == 1 || d.Month == 3 || d.Month == 5 || d.Month == 7 || d.Month == 8 || d.Month == 10 || d.Month == 12))
      {
         d.Day = 1;
         d.Month++;
         if (d.Month == 13)
         {
            d.Month = 1;
            d.Year++;
         }
      }
   }
   return d;
}

// Tick tock!
// The clock works perfectly fine
function Clock(int speed)
{
   tick -= 1*speed;
   if (tick <= 0)
   {
      CurrentDate.Second++;
      if (CurrentDate.Second == 60)
      {
         CurrentDate.Second = 0;
         CurrentDate.Minute++;
         if (CurrentDate.Minute == 60)
         {
            CurrentDate.Minute = 0;
            CurrentDate.Hour++;
            if (CurrentDate.Hour == 24)
            {
               CurrentDate.Hour = 0;
               CurrentDate.Day++;
               if (CurrentDate.Day > 28 && CurrentDate.Month == 2)
               {
                  if (!CurrentDate.IsLeap())
                  {
                     CurrentDate.Day = 1;
                     CurrentDate.Month++;
                  }
                  else 
                  {
                     if (CurrentDate.Day > 29)
                     {
                        CurrentDate.Day = 1;
                        CurrentDate.Month++;
                     }
                  }
               }
               if (CurrentDate.Day > 30 && (CurrentDate.Month == 11 || CurrentDate.Month == 4 || CurrentDate.Month == 6 || CurrentDate.Month == 9))
               {
                  CurrentDate.Day = 1;
                  CurrentDate.Month++;
               }
               else if (CurrentDate.Day > 31 && (CurrentDate.Month == 1 || CurrentDate.Month == 3 || CurrentDate.Month == 5 || CurrentDate.Month == 7 || CurrentDate.Month == 8 || CurrentDate.Month == 10 || CurrentDate.Month == 12))
               {
                  CurrentDate.Day = 1;
                  CurrentDate.Month++;
                  if (CurrentDate.Month == 13)
                  {
                     CurrentDate.Month = 1;
                     CurrentDate.Year++;
                  }
               }                      
            }
         }
      }
      tick = 40;
   }  
}

function repeatedly_execute()
{
   // Runs and displays the clock on screen in a certain room
   if (player.Room == 2)
   {
      Clock(1);
      String s = String.Format("%.2d-%.2d-%.2d \\ %.2d:%.2d:%.2d", CurrentDate.Day, CurrentDate.Month, CurrentDate.Year, CurrentDate.Hour, CurrentDate.Minute, CurrentDate.Second);
      lblCurrentTime.Text = s;
   }
}

function game_start()
{
   StartDate = new Date;
   CurrentDate = new Date;
   
   tick = 40;
   
   StartDate.Year = 3200;
   CurrentDate.Year = StartDate.Year;
   
   StartDate.Month = 1;
   CurrentDate.Month = StartDate.Month;
   
   StartDate.Day = 31;
   CurrentDate.Day = StartDate.Day;
   
   StartDate.Second = 50;
   CurrentDate.Second = StartDate.Second;
   
   StartDate.Minute = 59;
   CurrentDate.Minute = StartDate.Minute;
   
   StartDate.Hour = 23;
   CurrentDate.Hour = StartDate.Hour;
   String s = String.Format("%.2d-%.2d-%.2d \\ %.2d:%.2d:%.2d", CurrentDate.Day, CurrentDate.Month, CurrentDate.Year, CurrentDate.Hour, CurrentDate.Minute, CurrentDate.Second);
   lblCurrentTime.Text = s;   
}

export CurrentDate;
export StartDate;


Last but not least, I have bound to a key this block of code, which calls the above function and displays the returned string:
Code: ags
String s;
int a = DateToRaw(CurrentDate.Year, CurrentDate.Month, CurrentDate.Day); // = 1168441 
Date* b;
b = RawToDate(a);
s = String.Format("%.2d-%.2d-%.2d", b.Day, b.Month, b.Year);
return s;


Now, given all of this, the problem I'm having (actually me and vga256 are having, since he is helping me a great deal to crack this) is that, when I call this function:

1) If I don't subtract 1 from d.Year (check line 99 of the Time.asc): Months and Days show correctly, but the year is off.


2) If I subtract 1 from d.Year: the year is fine but Months and Days are quite off.


Does anyone have an idea why this is happening?

To give you an idea of what my ideal outcome would be, at the end of the day, here is a mockup:


Thanks a million in advance!
"We do not stop playing because we grow old, we grow old because we stop playing."

Snarky

Without delving into the code (and I see that you've got the calculation from Stackoverflow, which would require further study), I'm guessing it's very likely to do with starting the month and day counts at 1 instead of 0. It messes with any calculations that use division or modulo.

tzachs

Well, the hacky solution (without understanding the code, just based on the behavior you're describing) would be to move the "reduce one year" line to the end of the function, just before the return.

Note, though, that dealing with time is incredibly complex with like a billion of edge cases, so no matter what, test a lot.
Some reading material to appreciate the complexity:
https://infiniteundo.com/post/25326999628/falsehoods-programmers-believe-about-time
https://infiniteundo.com/post/25509354022/more-falsehoods-programmers-believe-about-time

LostTrainDude

Quote from: Snarky on Wed 13/06/2018 20:20:04
Without delving into the code (and I see that you've got the calculation from Stackoverflow, which would require further study), I'm guessing it's very likely to do with starting the month and day counts at 1 instead of 0. It messes with any calculations that use division or modulo.

I guess that part of the issue is that, starting from 0001-01-01 generates an incredibly high (and likely useless) number (1168441 days from 0001-01-01 to 3200-01-31). Because of this I can't run just the iteration because of the iteration limit for the for loop.

Quote from: tzachs on Thu 14/06/2018 00:38:51
Well, the hacky solution (without understanding the code, just based on the behavior you're describing) would be to move the "reduce one year" line to the end of the function, just before the return.

Note, though, that dealing with time is incredibly complex with like a billion of edge cases, so no matter what, test a lot.
Some reading material to appreciate the complexity:
https://infiniteundo.com/post/25326999628/falsehoods-programmers-believe-about-time
https://infiniteundo.com/post/25509354022/more-falsehoods-programmers-believe-about-time
The hacky solution works momentarily, but 3201 is not a leap year, while 3200 is, so you end up with this issue :( :



(OT: That website is amazing and not only for the falsehoods about time! Thanks for sharing!)
"We do not stop playing because we grow old, we grow old because we stop playing."

Snarky

What do you need the int representation for, anyway? If you just want to store it, you could trivially encode it as

Code: ags
 int dateCode = this.Day + this.Month * 100 + this.Year * 10000; // Gives YYYYMMDD


And decode it as:

Code: ags
  int day = dateCode % 100;
  int month = (datecode / 100) % 100;
  int year = datecode / 10000;

selmiak

Quote from: LostTrainDude on Thu 14/06/2018 08:24:42
I guess that part of the issue is that, starting from 0001-01-01 generates an incredibly high (and likely useless) number (1168441 days from 0001-01-01 to 3200-01-31). Because of this I can't run just the iteration because of the iteration limit for the for loop.

then start at 3000-01-01 or whatever is closest to the ingame start date and add the numbers, or rather start at 0001-01-01 for easier calculating and calculate this internal and just add 3000 or so years every time you put out a date for the player to see.

LostTrainDude

Quote from: selmiak on Thu 14/06/2018 18:11:55
then start at 3000-01-01 or whatever is closest to the ingame start date and add the numbers, or rather start at 0001-01-01 for easier calculating and calculate this internal and just add 3000 or so years every time you put out a date for the player to see.

That was one of my ideas as well but I honestly don't know how to twist the DateToRaw() function in order to have it start from year 3000. And, continuing on to Snarky's suggestion...

Quote from: Snarky on Thu 14/06/2018 13:30:57
What do you need the int representation for, anyway?

The reason I think I need these is that I would like to do date calculations "at leisure".
I would like to add dateB to dateA, as well as be able to know how many days\months\years of distance happen between the two.

For instance let's say that I want to set up a random Mission constructor that sets a due date that is 2 months from whatever the game's current date is, and stores both (e.g. startDate, dueDate).
Another example could be: to finish my current mission I have to travel from Planet X to Planet Y and to do so I need Z amount of days. Will I manage to be there before time is up?

Anyway, I like how your compact your code is :) I am not sure I can use it for all of the above purposes, though!

Another solution vga256 and me tried was to use Julian/Gregorian conversions and that seemed to be quite a good solution but unfortunately AGS doesn't really work well with float :(

Code: ags

float GtoJ(int year, int month, int day)
{
   float I = IntToFloat(year);
   float J = IntToFloat(month);
   float K = IntToFloat(day);
   
   return K-32075.0+1461.0*(I+4800.0+(J-14.0)/12.0)/4.0+367.0*(J-2.0-(J-14.0)/12.0*12.0)/12.0-3.0*((I+4900.0+(J-14.0)/12.0)/100.0)/4.0;
}


3200-01-31 in Julian date should be 2889865.500000, but what I get is this:



"We do not stop playing because we grow old, we grow old because we stop playing."

tzachs

You can try the answer here if you haven't already (it has 2 way conversions- days_from_civil and civil_from_days): https://stackoverflow.com/questions/7960318/math-to-convert-seconds-since-1970-into-date-and-vice-versa?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa

EDIT: Oh, and about this:

Quote
Another solution vga256 and me tried was to use Julian/Gregorian conversions and that seemed to be quite a good solution but unfortunately AGS doesn't really work well with float :(
I seriously doubt AGS doesn't "work well with floats", but rather floats as a data format have limitations which means you can't do accurate calculations with them. It's not AGS specific, all languages suffer from that, see here: http://floating-point-gui.de/
This is why some languages offer another data type which can do more accurate calculations, like decimals in c#, at the expense of performance.

Snarky

As for what's wrong with the code provided, one mistake is at line 98 in Time.asc:

Code: ags
   d.Year = rawdays / 365;


By the year 3200 there have been 800 - 32 + 8 = 776 leap days, more than two years (so I'm surprised that subtracting 1 is enough to give the right year at all).

I'm also highly dubious of the logic in lines 112 and 115:

Quoteif (d.IsLeap()) rawdays = (rawdays - howmanyleapyears) % 366;
   else rawdays = (rawdays - howmanyleapyears) % 365;

I think you always have to do mod 365 here, because the question isn't how many days are in this year, but how many days are in the years that have gone by. I think doing mod 365 on the raw day count and then subtracting the number of leap days (or vice versa, as in the code) is convoluted but correct, but the other variation is straight up wrong. You would then have to do another check to make sure you're not on day 366 of a non-leapyear.

LostTrainDude

#9
Working further with the code you provided I may have found what I am looking for (or at least what I am ok with!)

This is how I changed my script:
Code: ags
// Time.asc

int DateToRaw(int y, int m, int d)
{
   return d + m * 100 + y * 10000; // Gives YYYYMMDD
}

Date* RawToDate(int rawDate)
{
   Date* d = new Date;
   d.Day = rawDate % 100;
   d.Month = (rawDate / 100) % 100;
   d.Year = rawDate / 10000;
   
   return d;
}

int Date::RawDate()
{
   return DateToRaw(this.Year, this.Month, this.Day);
}

bool Date::IsLeap()
{
   return this.Year%4 == 0 && (this.Year%100 != 0 || this.Year%400 == 0);
}

function Date::Add(int days)
{
   int newRawDate = this.RawDate() + days;
   
   for (int i = this.RawDate(); i <= newRawDate; i++)
   {
      this.Day++;
      
      if (this.Day > 28 && this.Month == 2)
      {
         if (!this.IsLeap())
         {
            this.Day = 1;
            this.Month++;
         }
         else
         {
            if (this.Day > 29)
            {
               this.Day = 1;
               this.Month++;
            }
         }
      }
      else if (this.Day > 30 && (this.Month == 11 || this.Month == 4 || this.Month == 6 || this.Month == 9))
      {
         this.Day = 1;
         this.Month++;
      }
      else if (this.Day > 31 && (this.Month == 1 || this.Month == 3 || this.Month == 5 || this.Month == 7 || this.Month == 8 || this.Month == 10 || this.Month == 12))
      {
         this.Day = 1;
         this.Month++;
         if (this.Month == 13)
         {
            this.Month = 1;
            this.Year++;
         }
      }
   }
}


And just to provide the context:
Code: ags
// Missions.asc

// some other code

function CreateMission(int ID, MissionType type)
{
   // some other code
   
   Missions[ID].RawStartDate = CurrentDate.RawDate();
   Date* start = RawToDate(Missions[ID].RawStartDate);
   Date* due = start;
   due.Add(30);
   
   Missions[ID].RawDueDate = DateToRaw(due.Year, due.Month, due.Day);
   
   // some other code
}

String MissionText(int ID)
{
   String missionType;
   
   // some other code
   String p = planetarySystem[Missions[ID].TargetPlanet].name;
   
   Date* start = RawToDate(Missions[ID].RawStartDate);
   Date* due = RawToDate(Missions[ID].RawDueDate);
   
   String s;
   s = String.Format("%s is sent over to %s for %s, commissioned by The %s. Start: %.2d-%.2d-%.2d. Due: %.2d-%.2d-%.2d", People[Missions[ID].AssignedTo].name, p, missionType, Missions[ID].IssuingFaction, start.Day, start.Month, start.Year, due.Day, due.Month, due.Year);
   return s;
}


The output being this, where the due date is basically the start date + 30 days:


EDIT: the result is 31 days actually, but hmm, yeah it probably is something I messed up in the for loop in the Date::Add() function.
EDIT 2: Yeah I tweaked the for loop to be
Code: ags
for (int i = this.RawDate(); i < newRawDate; i++) // < instead of <=
and it works :)

It seems to be working, but I will test it more and see if I can use it as I intended to before confirming!

In the meantime thank you all! :) And feel free to ask for details or provide further feedback, if you want!
"We do not stop playing because we grow old, we grow old because we stop playing."

SMF spam blocked by CleanTalk