Page 1 of 1

Persistent map pins!

Posted: Thu Sep 08, 2005 12:21 pm
by Bruno Knotslinger
The thing I find most annoying about NWN PWs is the lack of persistency in the map pins. So of course, that was one of the early things that I coded for World of Greyhawk. Because it's (a) super simple, and (b) I play on other CoPaP worlds so I'm posting this code in the hopes that it will be implemented by others.

The following two functions can be placed in an include file of your choice. The first should be called in the OnClientEnter event, while the second should be called in the OnClientExit event (or, alternatively, if you wish to save the pins on every area transition, call it in the area OnExit):

Code: Select all

void GetMapPins (object oPC)
{
  // James Surles, WoG, August 2005
  int iCounter = 0;
  string sCounter;
  object oArea;
  string sPinText;
  float fXPos;
  float fYPos;
  string sAreaTagName;

  string sPCName = GetName(oPC);
  string sPlayerName = GetPCPlayerName(oPC);
  string sSelect = "SELECT pintext, xpos, ypos, areatag FROM mappins WHERE (pid=" +
                   IntToString(GetLocalInt(oPC,"ID")) + ")";

  SQLExecDirect(sSelect);
  int iResult = SQLFetch();
  while (iResult == SQL_SUCCESS)
  {
    sPinText = SQLDecodeSpecialChars(SQLGetData(1));
    fXPos = StringToFloat(SQLGetData(2));
    fYPos = StringToFloat(SQLGetData(3));
    sAreaTagName = SQLDecodeSpecialChars(SQLGetData(4));

    oArea = GetObjectByTag(sAreaTagName);
    if (GetIsObjectValid(oArea))
    {
      iCounter++;
      sCounter = IntToString(iCounter);
      SetLocalString(oPC, "NW_MAP_PIN_NTRY_"+sCounter, sPinText);
      SetLocalFloat(oPC, "NW_MAP_PIN_XPOS_"+sCounter, fXPos);
      SetLocalFloat(oPC, "NW_MAP_PIN_YPOS_"+sCounter, fYPos);
      SetLocalObject(oPC, "NW_MAP_PIN_AREA_"+sCounter, oArea);
    }
    iResult = SQLFetch();
  }
  SetLocalInt(oPC, "NW_TOTAL_MAP_PINS", iCounter);
  return;
}

Code: Select all

void ExportMapPins(object oPC)
{
  // James Surles, WoG, August 2005
  int iCounter;
  string sCounter;
  object oArea;
  string sPinText;
  float fXPos;
  float fYPos;
  string sAreaTagName;
  string sInsert = "";

  string sPCID = IntToString(GetLocalInt(oPC,"ID"));

  string sDelete = "DELETE FROM mappins WHERE (pid=" + sPCID + ")";
  SQLExecDirect(sDelete);

  int iNumPins = GetLocalInt(oPC, "NW_TOTAL_MAP_PINS");
  if (iNumPins >= 1)
  {
    for (iCounter=1; iCounter <= iNumPins; iCounter++)
    {
      sCounter = IntToString(iCounter);
      sPinText = GetLocalString(oPC, "NW_MAP_PIN_NTRY_"+sCounter);
      fXPos = GetLocalFloat(oPC, "NW_MAP_PIN_XPOS_"+sCounter);
      fYPos = GetLocalFloat(oPC, "NW_MAP_PIN_YPOS_"+sCounter);
      oArea = GetLocalObject(oPC, "NW_MAP_PIN_AREA_"+sCounter);
      if ((GetStringLength(sPinText) > 0) && GetIsObjectValid(oArea))
      {
        sAreaTagName = GetTag(oArea);
        if (iCounter==1){
          sInsert = "(" + sPCID + "," + FloatToString(fXPos) + "," + FloatToString(fYPos) +
                  ",'" + SQLEncodeSpecialChars(sAreaTagName) + "','" + SQLEncodeSpecialChars(sPinText) + "')";
        }
        else{
          sInsert = "(" + sPCID + "," + FloatToString(fXPos) + "," + FloatToString(fYPos) +
                  ",'" + SQLEncodeSpecialChars(sAreaTagName) + "','" + SQLEncodeSpecialChars(sPinText) + "')" + "," + sInsert;
        }
      }
    } // End for
    sInsert = "INSERT INTO mappins (pid,xpos,ypos,areatag,pintext) VALUES " + sInsert;
    SQLExecDirect(sInsert);
  } // End if

  return;
}
The table "mappins" has the following format:
pid - Integer
xpos - Float
ypos - Float
areatag - Varchar (64)
pintext - Varchar (255)

At WoG, we use a unique integer to identify players (pid) in our tables rather than the Player Name + Character Name combination, but the code above can be easily changed to accomodate Player Name + Character Name.

The one annoying thing is that on server reset, all previously explored areas are returned to a covered state on the map, and saved pins will not show up until the player uncovers the spot the pin is in. Better than nothing. Of course, on WoG, we are going to uncover the maps (completely) of areas that a character has spent more than 12 hours (in-game time) in. (The code for that can be posted as well if anyone wants to see it... it's also super-simple.)

Posted: Fri Sep 09, 2005 12:50 am
by JollyOrc
post ahead :)

Posted: Fri Sep 09, 2005 12:57 am
by JollyOrc
question: this only saves player made map pins, right ? Those that are server side won't be saved ?

Posted: Fri Sep 09, 2005 10:20 am
by Bruno Knotslinger
JollyOrc wrote:question: this only saves player made map pins, right ? Those that are server side won't be saved ?
Good question. One I've been meaning to test, in fact. This gives me the perfect impetus to do so.

*furiously opens the toolset and places a map note waypoint*

My guess is no. Only user-made map pins are saved.

*waits impatiently for the module to save*

*waits impatiently for toolset to finally close*

*fires up nwnx and impatiently waits for the module to fire up and come online*

Yup. Only user-made map pins are saved. I'll post the code for tracking time spent in an area in a bit.

Posted: Fri Sep 09, 2005 10:42 am
by Bruno Knotslinger

Code: Select all

void CheckExploreArea(object oArea, object oPC){
  // James Surles, WoG, August 2005
  string sAreaName = GetTag(oArea);
  int iTimeSpent = GetPCInt(oPC, "iTime"+sAreaName);
  SetLocalDateTime(oPC, "dtLastEnterTime", getCurrentDateTime());

  // 12 in-game hours = 43200, 6 in-game hours = 21600
  // 4 in-game hours = 14400, 2 in-game hours = 7200
  if (iTimeSpent > 43200){
    ExploreAreaForPlayer(oArea, oPC);
  }
  else if (iTimeSpent == 0){
    // Create the variable so it exists for the OnExit script.
    SetPCInt(oPC, "iTime"+sAreaName, 0);
  }

  return;
}

Code: Select all

void SaveExploreArea(object oArea, object oPC){
  // James Surles, WoG, August 2005
  string sAreaName = GetTag(oArea);
  int iTimeSpent = GetPCInt(oPC, "iTime"+sAreaName);
  struct DateTime dtExitTime = getCurrentDateTime();
  struct DateTime dtEnterTime = GetLocalDateTime(oPC, "dtLastEnterTime");
  struct DateTime dtDiffTimes = subDateTime(dtExitTime, dtEnterTime);
  int iSecondsSpentHere = dtDiffTimes.second + dtDiffTimes.minute*60 +
                        dtDiffTimes.hour*3600 + dtDiffTimes.day*86400 +
                        dtDiffTimes.month*2419200 + dtDiffTimes.year*29030400;
  SetPCInt(oPC, "iTime"+sAreaName, iTimeSpent+iSecondsSpentHere);
  WriteTimestampedLogEntry("Saving area info for " + GetName(oPC) + " in area " + GetTag(oArea));

  return;
Couple of notes:

You don't have Get/SetPCInt. It's the same as Get/SetPersistentInt, except our world leader wanted the variable type stored in the table as well (a good idea). So, in short, replace Get/SetPCInt with Get/SetPersistentInt.

The time before the map is exposed in CheckExploreArea is set assuming 2 real minutes per game hour. That ought to be adjusted to account for your individual tastes. Also, although invisible to the players, unless the 1 in-game hour is equal to 1 real-time hour, the number of seconds jumps every time another hour rolls around. No big deal, but I thought I'd mention it so that nobody thinks there's some kind of bug if someone decides to create an object that tells players how long they've been in an area.

One final note on Get/SetPCInt (and all of those functions). To save on database accesses, one of the things that our WL wants to do is cache all persistent variables at OnClientEnter, so that we only have to use GetLocal* to access them. We still have to use SetPC* every time it's changed and needs to be updated in the table (since there's no way to loop through local variables that we know of and write them out to the table OnClientExit). So this code will be modified in the future somewhat to use only a GetLocalInt in the CheckExploreArea function, as will all code that uses GetPersistent*.

Oh! CheckExploreArea goes in the area OnEnter script, obviously. SaveExploreArea goes in the area OnExit script, just as obviously, but it should also go in the OnClientExit script as well to save the information for the area that a player was in when they left the server.

Posted: Fri Sep 09, 2005 10:48 am
by Bruno Knotslinger
Oops... and you also need to include "datetime2".

Posted: Fri Sep 09, 2005 11:04 am
by Themicles
Just a note that the addDateTime function in datetime2 currently is not working as intended. If you feed it a number of months over 12, it correctly advances the year, but does not set the month properly.

For instance, I've given it 15 months to add to the current date, and noticed that it does, indeed, add 1 year, but it sets the month to 13, rather than 3.

EDIT: I have tried to figure out why this happens, but am entirely unsure.
I don't even know of addDateTime is relevant to the scripts above, but figured I'd mention it anyway.

-Themicles

Posted: Fri Sep 09, 2005 11:10 am
by Bruno Knotslinger
Themicles wrote: EDIT: I have tried to figure out why this happens, but am entirely unsure.
I don't even know of addDateTime is relevant to the scripts above, but figured I'd mention it anyway.
No, it's not used in the above, but I thank you for the head's up. I will take a look at it after I get back from lunch and see if I can offer any guidance or a fix.

Posted: Fri Sep 09, 2005 11:22 am
by Bruno Knotslinger
If you're adding 15 months, then if the current month is, say, 9 or greater, the sum of the two is 24, which should add 2 years and return a result of 0 months. But since it only goes through the if statement once, it will set the month to 15+9-12 = 12, and add only one year.

This isn't an issue if you are adding datetimes that are obtained through the use of the "get" functions (i.e. those that don't have any values that should be rolled over).

But to make it bulletproof, you'll have to do a loop such as:

Code: Select all

while (date1.millisecond+date2.millisecond > 999)
{
  date1.second++;
  overflow += 1000;
}
date1.millisecond = date1.millisecond + date2.millisecond - overflow;
overflow = 0;
And so on for each...

I would think that you would also have to do something similar with the function to subtract datetimes.

[EDIT: Oops... the above code won't work, but my wife is about to pick me up for lunch, so I'll have to leave it broken for now.]

Posted: Fri Sep 09, 2005 12:23 pm
by Bruno Knotslinger
Okay, the following should work (completely untested, though... in fact, I wrote it in Notepad, so I don't even know if it'll compile):

Code: Select all

//
// Add two DateTimes
//
struct DateTime addDateTime(struct DateTime date1, struct DateTime date2)
{
    // add milliseconds
    date1.millisecond += date2.millisecond;
    while (date1.millisecond > 999)
    {
        date1.second++;
        date1.millisecond -= 1000;
    }

    // add seconds
    date1.second += date2.second;
    while (date1.second > 59)
    {
        date1.minute++;
        date1.second -= 60;
    }

    // add minutes
    date1.minute += date2.minute;
    while (date1.minute > 59)
    {
        date1.hour++;
        date1.minute -= 60;
    }

    // add hours
    date1.hour += date2.hour;
    while (date1.hour > 23)
    {
        date1.day++;
        date1.hour -= 24;
    }

    // add days
    date1.day += date2.day;
    while (date1.day > 28)
    {
        date1.month++;
        date1.day -= 28;
    }

    // add months
    date1.month += date2.month;
    while (date1.month > 12)
    {
        date1.year++;
        date1.month -= 12;
    }

    // add years
    date1.year = date1.year + date2.year;

    return date1;
}

Posted: Fri Sep 09, 2005 1:18 pm
by Bruno Knotslinger
Perhaps this belongs in a thread all its own, but even this code isn't quite so "bulletproof", as there's no protection against someone passing a datetime that has a negative number for one of the values.

subDateTime suffers from the same malady that addDateTime suffers from, but it's actually a lot worse because you have the possibility not only of that, but of someone passing a date1 that is actually an earlier date than date2.

Posted: Fri Sep 09, 2005 2:01 pm
by Bruno Knotslinger
Gah! I keep thinking of new things to say.

The above "problems" aren't necessarily a bad thing, per se. Regardless if there are negative values and such, either the original addDateTime or my modified addDateTime preserves the ability to calculate, for example, milliseconds or seconds since (0:0:0, 0/0/0), even if those seconds are negative. The only "problem" is that we'd like to see it in a comfortable format. While I can likely think up some good reasons outside of NWN to allow the subtraction date1-date2 where date1 is actually before date2, I haven't been able to think of a good reason inside of NWN to do it.

Posted: Wed Sep 14, 2005 4:47 am
by Talwin Hawkins
Not so interested in the Date bit, but the persistant map pins sounds great. This going to be implemented Copap wide? If not, why not?

Posted: Wed Sep 14, 2005 5:30 am
by JollyOrc
Talwin Hawkins wrote:Not so interested in the Date bit, but the persistant map pins sounds great. This going to be implemented Copap wide? If not, why not?
every CoPaP world decides this on it's own. I guess Catara will get this sooner or later, but I've got a buttload of systems lined up for installing, so this may take a while.

Posted: Wed Sep 14, 2005 9:11 am
by Talwin Hawkins
when i say why not I mean is there a reason (other than time and effort) why this wouldnt be implemented? As in lag, incomplete code etc etc.

Posted: Wed Sep 14, 2005 9:13 am
by JollyOrc
Talwin Hawkins wrote:when i say why not I mean is there a reason (other than time and effort) why this wouldnt be implemented? As in lag, incomplete code etc etc.
I think mostly because no one has so far thought this up. There could be some lag problems, but I don't think so.

Posted: Wed Sep 14, 2005 10:06 am
by Bruno Knotslinger
JollyOrc wrote:I think mostly because no one has so far thought this up. There could be some lag problems, but I don't think so.
If the function to save the pins (ExportMapPins) is placed in the OnClientExit event as opposed to the area OnExit event, there should be no lag problems. If in the area OnExit event, however, I can foresee some lag problems as people who place oodles of mappins would have substantive database action going on for every area transition (on top of the substantive database action that's already going on).

---

Another thing of note is that as a matter of personal choice, we decided not to save pins that had no text attached to them. That can easily be changed by simply removing the "(GetStringLength(sPinText) > 0)" from the if statement in ExportMapPins.

A side-effect of this is that if a player has map pins that don't have any text in them, the player will have some extra local variables floating around that aren't being used. If you are anal enough about that, you can add some DeleteLocal*'s in ExportMapPins as you go through the for loop. You can also do it in GetMapPins as NW_TOTAL_MAP_PINS isn't messed with by ExportMapPins.

Updated!

Posted: Fri Oct 27, 2006 1:26 pm
by Bruno Knotslinger
Notes:
A call to GetMapPins should be in the OnClientEnter script, immediately preceded by the definition of sPCPlayerName. As such:

Code: Select all

//Load PC Mappins
SetLocalString(oPC, "sPCPlayerName", GetPCPlayerName(oPC));
GetMapPins(oPC);
A call to ExportMapPins should be in the OnClientLeave.

Currently, it only stores the most recent 100 pins marked by the player as persistent. Even this may cause some lag on player enter (only the first time they enter, as there's no point in reading the pins more than once between server resets) and player exit. Haven't tested it with multiple players on my test server, so don't really know.

To make a map pin persistent, the very first three characters in the pin MUST be either "!P!" or "!p!". Even something like " !P!" won't work. You can modify it using FindSubString instead and checking that it's != -1, but that function is slower than GetStringLeft since it has to search the entire string (at worst). But in that case, all they have to have is !P! somewhere in the pin text.

If you want ALL map pins to be persistent, then the approach I would take to doing that would be to:
(a) Obviously remove the check for the !P! and the check for iNumStored < 100.
(b) Read the pins in in batches of 100 (or whatever) using the "pinnum" column in the table.

Code: Select all

    ("...WHERE (pinnum >= " + IntToString(iCurrIndex) + " AND pinnum < " + IntToString(iCurrIndex+100) + ")...")
Have a 5 second delay between each batch.
(c) Write the pins in batches of 100 (or whatever).

This would cut down on lag, and would prevent TMI errors.

Here's the table format (SQL code for creating):

Code: Select all

#----------------------------
# Table structure for mappins
#----------------------------
CREATE TABLE `mappins` (
  `pinnum` int(11) NOT NULL default '0',
  `player` varchar(255) NOT NULL default '',
  `pcname` varchar(255) NOT NULL default '',
  `xpos` float NOT NULL default '0',
  `ypos` float NOT NULL default '0',
  `areatag` varchar(64) NOT NULL default '',
  `pintext` varchar(255) NOT NULL default '',
  PRIMARY KEY  (`pinnum`,`player`,`pcname`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COMMENT='Stores Mappins';
Table name: mappins
Columns: pinnum (int, length=11, key)
player (varchar, length=255, key)
pcname (varchar, length=255, key)
xpos (float)
ypos (float)
pintext (varchar, length=255)

Of course, the player and pcname columns could be shorter than 255 characters. Also, the pin text could be considerably longer than 255, but this will only store the first 255 characters of their pin (by preference).

Code for the include file

Code: Select all

/*
map_inc
Persistent map pins.
James Surles, October, 2006.
*/

#include "aps_inc"


/*
Prototypes
*/

//map_inc
//Get a Players Map Pins
//oPC = Player
void GetMapPins (object oPC);

//map_inc
//Export Players Map Pins
//oPC = Player
void ExportMapPins(object oPC);


/*
Implementation
*/
void GetMapPins (object oPC)
{
  int iCounter = 0;
  string sCounter;
  object oArea;
  string sPinText;
  float fXPos;
  float fYPos;
  string sAreaTagName;

  if (GetLocalInt(oPC, "NW_TOTAL_MAP_PINS") > 0)
  {
    /* Already have map pins defined.  That means that they've logged in since the last
       server reboot and don't require pins be loaded again. */
    return;
  }
  string sPlayer = SQLEncodeSpecialChars(GetLocalString(oPC, "sPCPlayerName"));
  string sPCName = SQLEncodeSpecialChars(GetName(oPC));

  string sSelect = "SELECT pintext, xpos, ypos, areatag FROM mappins WHERE (player='" + sPlayer + "' AND pcname='" + sPCName + "') ORDER BY pinnum";

  SQLExecDirect(sSelect);
  int iResult = SQLFetch();
  while (iResult == SQL_SUCCESS)
  {
    sPinText = SQLDecodeSpecialChars(SQLGetData(1));
    fXPos = StringToFloat(SQLGetData(2));
    fYPos = StringToFloat(SQLGetData(3));
    sAreaTagName = SQLDecodeSpecialChars(SQLGetData(4));

    oArea = GetObjectByTag(sAreaTagName);
    if (GetIsObjectValid(oArea))
    {
      iCounter++;
      sCounter = IntToString(iCounter);
      SetLocalString(oPC, "NW_MAP_PIN_NTRY_"+sCounter, sPinText);
      SetLocalFloat(oPC, "NW_MAP_PIN_XPOS_"+sCounter, fXPos);
      SetLocalFloat(oPC, "NW_MAP_PIN_YPOS_"+sCounter, fYPos);
      SetLocalObject(oPC, "NW_MAP_PIN_AREA_"+sCounter, oArea);
    }
    iResult = SQLFetch();
  }
  SetLocalInt(oPC, "NW_TOTAL_MAP_PINS", iCounter);
  return;
}

void ExportMapPins(object oPC)
{
  int iCounter, iNumStored;
  string sCounter;
  object oArea;
  string sPinText;
  float fXPos;
  float fYPos;
  string sAreaTagName;
  string sInsert = "";

  string sPlayer = SQLEncodeSpecialChars(GetLocalString(oPC, "sPCPlayerName"));
  string sPCName = SQLEncodeSpecialChars(GetName(oPC));

  string sDelete = "DELETE FROM mappins WHERE (player='" + sPlayer +"' AND pcname='" + sPCName +"')";
  SQLExecDirect(sDelete);

  iCounter = GetLocalInt(oPC, "NW_TOTAL_MAP_PINS");
  iNumStored = 0;

  while ((iCounter > 0) && (iNumStored < 100))
  {
    sCounter = IntToString(iCounter);
    sPinText = GetLocalString(oPC, "NW_MAP_PIN_NTRY_"+sCounter);
    if (GetStringUpperCase(GetStringLeft(sPinText,3)) == "!P!")
    {
      /* Marked as persistent, so store it in the database. */

      fXPos = GetLocalFloat(oPC, "NW_MAP_PIN_XPOS_"+sCounter);
      fYPos = GetLocalFloat(oPC, "NW_MAP_PIN_YPOS_"+sCounter);
      oArea = GetLocalObject(oPC, "NW_MAP_PIN_AREA_"+sCounter);
      if (GetIsObjectValid(oArea))
      {
        sAreaTagName = GetTag(oArea);
        if (iNumStored==0)
        {
          sInsert = "("+ sCounter + ", '" + sPlayer + "','" + sPCName +"'," + FloatToString(fXPos) + "," + FloatToString(fYPos) +
                  ",'" + SQLEncodeSpecialChars(sAreaTagName) + "','" + SQLEncodeSpecialChars(sPinText) + "')";
        }
        else
        {
          sInsert = "("+ sCounter + ", '" + sPlayer + "','" + sPCName +"'," + FloatToString(fXPos) + "," + FloatToString(fYPos) +
                  ",'" + SQLEncodeSpecialChars(sAreaTagName) + "','" + SQLEncodeSpecialChars(sPinText) + "')" + "," + sInsert;
        }
        iNumStored++;
      } // End inner if
    } // End outer if
    iCounter--;
  } // End while

  if (iNumStored > 0)
  {
    sInsert = "INSERT INTO mappins (pinnum,player,pcname,xpos,ypos,areatag,pintext) VALUES " + sInsert;
    SQLExecDirect(sInsert);
  }

  return;
}